Designing a Scalable Parking Lot System: A Low-Level Design Guide in Java
INTRODUCTION
If you’ve ever driven into a mall, a hospital, or an office complex, you’ve interacted with a parking lot system. You pull up, take a ticket, park your car, and eventually pay based on how long you stayed.
But what happens behind the scenes? How does the system know where to send you? How does it make sure a massive bus doesn’t try to park in a compact motorcycle spot?
In this article, we are going to dive into the Low-Level Design (LLD) of a Parking Lot System using Java. We will explore the core components, design patterns, and the “why” behind every line of code so you can build a system that is robust, scalable, and easy to maintain.
Problem Statement & Requirements
Before writing any code, we need to understand what we are building. A Parking Lot System must handle:
Functional Requirements:
- Multiple Floors & Spots: The parking lot will have several floors, and each floor will have various parking spots (Small, Medium, Large).
- Vehicle Types: It must support Motorcycles (Small), Cars (Medium), and Trucks/Buses (Large).
- Parking Allocation: When a vehicle enters, the system must assign it the most appropriate available spot (e.g., a “Best Fit” strategy to avoid wasting large spots on small bikes).
- Ticketing & Billing: It must generate a ticket on entry and calculate the fee upon exit based on time spent and the type of vehicle.
- Concurrency: It must be thread-safe. Two cars entering simultaneously shouldn’t be assigned the exact same parking spot.
Non-Functional Requirements:
- Extensibility: We should be able to easily add new pricing models (e.g., weekend rates) or new allocation strategies (e.g., VIP parking).
- Maintainability: The code must follow Object-Oriented Programming (OOP) and SOLID principles.
Core Entities (The Model Layer)
We will start by building the core building blocks of our system. These are plain Java objects that represent the physical items in the real world.
Vehicle & Vehicle Types
First, we define an Enum for our vehicle sizes. This is much better than using simple strings because it provides type safety and makes it easy to add new sizes later.
public enum VehicleSize {
SMALL, MEDIUM, LARGE
}Next, we define a base Vehicle class. Other specific vehicle types (like Bike, Car, Truck) will inherit from this base class.
public abstract class Vehicle {
private final String licenseNumber;
private final VehicleSize size;
public Vehicle(String licenseNumber, VehicleSize size) {
this.licenseNumber = licenseNumber;
this.size = size;
}
public String getLicenseNumber() { return licenseNumber; }
public VehicleSize getSize() {
return size;
}
}Because we made Vehicle an abstract class, adding specific vehicles is incredibly simple. This is a perfect example of the Open/Closed Principle our system is open for extension, but closed for modification.
If we ever need to add an ElectricCar in the future, we just create a new class. The core parking logic won't need to change at all.
public class Bike extends Vehicle {
public Bike(String licenseNumber) {
super(licenseNumber, VehicleSize.SMALL);
}
}
public class Car extends Vehicle {
public Car(String licenseNumber) {
super(licenseNumber, VehicleSize.MEDIUM);
}
}
public class Truck extends Vehicle {
public Truck(String licenseNumber) {
super(licenseNumber, VehicleSize.LARGE);
}
}The Parking Spot
A ParkingSpot represents a physical space. It needs to know its ID, its size, whether it is occupied, and what vehicle is currently parked there.
Get Aarav Gupta’s stories in your inbox
Join Medium for free to get updates from this writer.
Crucial Logic: We need a method to check if a specific vehicle can fit in a spot. For instance, a medium spot can fit a small or medium vehicle, but not a large one.
public class ParkingSpot {
private final String spotId;
private boolean isTaken;
private Vehicle parkedVehicle;
private final VehicleSize spotCapacity;
public ParkingSpot(String spotId, VehicleSize spotCapacity) {
this.spotId = spotId;
this.spotCapacity = spotCapacity;
this.isTaken = false;
this.parkedVehicle = null;
}
public synchronized boolean isAvailable() {
return !isisTaken;
}
public boolean isTaken() {
return isTaken;
}
public synchronized void parkVehicle(Vehicle vehicle) {
this.parkedVehicle = vehicle;
this.isTaken = true;
}
public synchronized void unparkVehicle() {
this.parkedVehicle = null;
this.isTaken = false;
}
public boolean canFitVehicle(Vehicle vehicle) {
if (isTaken) return false;
switch (vehicle.getSize()) {
case SMALL:
return spotCapacity == VehicleSize.SMALL;
case MEDIUM:
return spotCapacity == VehicleSize.MEDIUM || spotSize == VehicleSize.LARGE;
case LARGE:
return spotCapacity == VehicleSize.LARGE;
default:
return false;
}
}
}Note the synchronized keyword. This is critical for thread safety. It ensures that if two threads try to park a vehicle in the exact same spot at the exact same millisecond, one will be forced to wait.
The Parking Floor
A ParkingFloor acts as a container for multiple ParkingSpot objects. Its primary responsibility is to manage its own spots and quickly find an available one when asked.
public class ParkingFloor {
private final int floorNumber;
private final Map<String, ParkingSpot> spots;
public ParkingFloor(int floorNumber) {
this.floorNumber = floorNumber;
// Using ConcurrentHashMap for thread safety at the floor level
this.spots = new ConcurrentHashMap<>();
}
public void addSpot(ParkingSpot spot) {
spots.put(spot.getSpotId(), spot);
}
// This method uses Java Streams to efficiently find an available spot
public synchronized Optional<ParkingSpot> findAvailableSpot(Vehicle vehicle) {
return spots.values().stream()
.filter(spot -> !spot.isTaken() && spot.canFitVehicle(vehicle))
.findFirst();
}
}Notice how clean the findAvailableSpot method is. By using the Java Stream API, we filter out occupied spots, check if the vehicle fits, and grab the first available one.
The Parking Ticket
When a vehicle parks, we need to generate a ticket. We use UUID.randomUUID() to ensure every ticket gets a completely unique ID, preventing collisions in the system.
public class ParkingTicket {
private final String ticketId;
private final Vehicle vehicle;
private final ParkingSpot spot;
private final long entryTime;
private long exitTime;
public ParkingTicket(Vehicle vehicle, ParkingSpot spot) {
this.ticketId = UUID.randomUUID().toString();
this.vehicle = vehicle;
this.spot = spot;
this.entryTime = new Date().getTime();
}
// ... Getters and Setters ...
}The Design Patterns
To make our system scalable and maintainable, we will implement two powerful Design Patterns.
Pattern 1: The Singleton (ParkingLot)
There should only ever be one instance of the ParkingLot orchestrating the entire building. To enforce this, we use the Singleton pattern.
public class ParkingLot {
private static ParkingLot instance;
private final List<ParkingFloor> floors = new ArrayList<>();
private final Map<String, ParkingTicket> activeTickets;
// Strategies (We will explain these next)
private FeeStrategy feeStrategy;
private ParkingStrategy parkingStrategy;
// Private constructor prevents outside instantiation
private ParkingLot() {
this.activeTickets = new ConcurrentHashMap<>();
}
// Synchronized ensures thread-safe creation of the single instance
public static synchronized ParkingLot getInstance() {
if (instance == null) {
instance = new ParkingLot();
}
return instance;
}
// ...
}Notice the use of ConcurrentHashMap for activeTickets. Standard HashMaps crash under heavy multi-threading. ConcurrentHashMap is explicitly designed for high-traffic environments.
Pattern 2: The Strategy Pattern (Fees & Parking Algorithms)
Imagine the mall owner says, “We want to charge a flat rate on weekdays, but an hourly rate on weekends based on the vehicle size.” If you hardcode your fee logic into the ParkingLot class, you will have to rewrite core code every time the business requirements change.
Instead, we use the Strategy Pattern. We define an interface, and then create different classes that implement that interface.
Fee Strategy
public interface FeeStrategy {
double calculateFee(ParkingTicket parkingTicket);
}
// Strategy A: Vehicle-based pricing
public class VehicleBasedFeeStrategy implements FeeStrategy {
private static final Map<VehicleSize, Double> HOURLY_RATES = Map.of(
VehicleSize.SMALL, 20.0,
VehicleSize.MEDIUM, 50.0,
VehicleSize.LARGE, 80.0
);
@Override
public double calculateFee(ParkingTicket ticket) {
long duration = ticket.getExitTime() - ticket.getEntryTime();
long hours = (duration / (1000 * 60 * 60)) + 1; // Round up
return hours * HOURLY_RATES.get(ticket.getVehicle().getSize());
}
}Parking Allocation Strategy
We do the exact same thing for how we find spots. Here is a “Best Fit” strategy that searches all floors to find the smallest possible spot that safely fits the vehicle, ensuring we don’t waste Large spots on Small bikes.
public interface ParkingStrategy {
Optional<ParkingSpot> findSpot(List<ParkingFloor> floors, Vehicle vehicle);
}
public class BestFitStrategy implements ParkingStrategy {
@Override
public Optional<ParkingSpot> findSpot(List<ParkingFloor> floors, Vehicle vehicle) {
Optional<ParkingSpot> bestSpot = Optional.empty();
for (ParkingFloor floor : floors) {
Optional<ParkingSpot> spotOnThisFloor = floor.findAvailableSpot(vehicle);
if (spotOnThisFloor.isPresent()) {
if (bestSpot.isEmpty()) {
bestSpot = spotOnThisFloor;
} else {
// Compare enums: A smaller ordinal means a tighter, better fit
if (spotOnThisFloor.get().getSpotCapacity().ordinal() < bestSpot.get().getSpotCapacity().ordinal()) {
bestSpot = spotOnThisFloor;
}
}
}
}
return bestSpot;
}
}Notice the use of Optional<ParkingSpot>. Instead of returning null when the lot is full (which causes the dreaded NullPointerException), we return an Optional. This forces the calling code to safely handle the scenario where no spot was found.
Because we used the Strategy Pattern, we could easily swap this out for a FarthestFirstStrategy or VIPStrategy without changing any code in the ParkingLot class.
Tying it all Together: The Core Service
Finally, we implement the core parkVehicle and unparkVehicle methods inside our ParkingLot singleton.
public Optional<ParkingTicket> parkVehicle(Vehicle vehicle) {
// 1. Ask our strategy to find a spot
Optional<ParkingSpot> availableSpot = parkingStrategy.findSpot(floors, vehicle);
// 2. If a spot is found, execute parking logic
if (availableSpot.isPresent()) {
ParkingSpot spot = availableSpot.get();
spot.parkVehicle(vehicle);
// 3. Generate ticket and store it
ParkingTicket ticket = new ParkingTicket(vehicle, spot);
activeTickets.put(vehicle.getLicenseNumber(), ticket);
return Optional.of(ticket);
}
return Optional.empty(); // Lot is full
}
public Optional<Double> unparkVehicle(String licenseNumber) {
// 1. Retrieve the ticket
ParkingTicket ticket = activeTickets.remove(licenseNumber);
if (ticket == null) return Optional.empty();
// 2. Mark exit time and free the physical spot
ticket.setExitTime();
ticket.getSpot().unparkVehicle();
// 3. Delegate fee calculation to our Strategy
Double parkingFee = feeStrategy.calculateFee(ticket);
return Optional.of(parkingFee);
}Leveling Up: Exception Handling
In a production environment, simply returning Optional.empty() or null when something goes wrong isn't always enough. A robust system uses Custom Domain Exceptions to clearly communicate errors.
If an interviewer asks you how to make this system more robust, you can introduce custom runtime exceptions
public class ParkingLotFullException extends RuntimeException {
public ParkingLotFullException(String message) {
super(message);
}
}
public class InvalidTicketException extends RuntimeException {
public InvalidTicketException(String message) {
super(message);
}
}You would then update your unparkVehicle method to throw this exception if a user hands the system a fake or already-processed ticket
public double unparkVehicle(String licenseNumber) {
ParkingTicket ticket = activeTickets.remove(licenseNumber);
if (ticket == null) {
throw new InvalidTicketException("No active ticket found for vehicle: " + licenseNumber);
}
// ... rest of the unpark logic
}This guarantees your program fails fast and loudly, rather than failing silently and causing data inconsistencies down the road.
The Class Diagram (System Structure)
The Sequence Diagram (The Parking Flow)
This diagram maps out the exact step-by-step logic of what happens when a car tries to park using your BestFitStrategy.
Cracking the LLD Interview: What Interviewers Look For
If you are asked to design a Parking Lot in a system design interview, the interviewer isn’t just checking if your code compiles. They are evaluating your architectural mindset. Here is how to score top marks:
- Start with the Core Entities (Bottom-Up Approach): Don’t start writing the
ParkingLotclass first. Start with the smallest piecesVehicle,ParkingSpot, andEnums. Build up to the complex orchestrators.
2. Nail the Concurrency: A parking lot is a highly concurrent environment. Mentioning ConcurrentHashMap and the synchronized keyword shows you understand race conditions and thread safety.
3. Use Design Patterns to solve actual problems: Don’t just throw patterns in to look smart. Use the Strategy Pattern to explicitly solve the problem of “changing business requirements” (like dynamic pricing or new parking algorithms).
4. Think about Extensibility: Always ask yourself, “What if the client wants to add Electric Vehicle charging spots?” Because of our modular design, we could easily add a ChargingSpot class that extends ParkingSpot without breaking the system.
Conclusion
If you’ve made it this far, congratulations! You’ve successfully navigated one of the most notorious Low-Level Design interview questions out there.
Designing a parking lot might seem like a simple real-world problem, but as we’ve seen, translating it into robust code exposes the true challenges of software architecture. By leveraging Object-Oriented principles and design patterns like Singleton and Strategy, we didn’t just write a script that parks cars, we built a system that can evolve.
In a Low-Level Design interview, this is exactly what your interviewer is looking for. They don’t just want to see if you can solve today’s problem, they want to see if your code can survive tomorrow’s changing business requirements. If management suddenly demands VIP parking zones or dynamic holiday pricing, our architecture handles it with a simple class addition, not a massive rewrite.
System Design is a journey of continuous learning, and there is rarely one single “perfect” answer. I highly encourage you to take this code, throw some new edge cases at it, and see how it holds up. Keep practicing, keep building, and remember: great system design isn’t just about making things work, it’s about making things adapt. Happy coding, and good luck in your interviews!




