A well-designed system separates concerns clearly: domain modeling, allocation logic, pricing, and orchestration. This separation ensures that changes in one part do not ripple unnecessarily into others.
A parking lot consists of multiple floors and slots. Vehicles of different types such as bikes, cars, and trucks enter the system, are assigned a compatible slot, and exit after payment. The system must efficiently track available slots, manage parking sessions, and calculate charges based on duration.
Unlike naive designs, scanning all slots for every request is inefficient. Instead, we aim for constant-time allocation using optimized data structures and concurrent-safe mechanisms.
Domain Modeling
Vehicle
At the core of the system lies the Vehicle abstraction, which encapsulates the identity and type of a vehicle. This allows the system to enforce compatibility rules between vehicles and parking slots.enum VehicleType {
BIKE,
CAR,
TRUCK
}
abstract class Vehicle {
private final String licenseNumber;
private final VehicleType type;
public Vehicle(String licenseNumber, VehicleType type) {
this.licenseNumber = licenseNumber;
this.type = type;
}
// Getters
}
This abstraction ensures that adding new vehicle types in the future does not affect existing logic.
Parking Structure
The parking lot is composed of floors, each containing multiple parking slots. Unlike simplistic designs that use enums for floors, we model floors as objects to allow scalability.Each ParkingSlot is responsible for maintaining its own state, making it the single source of truth for occupancy.
import java.util.UUID;
class ParkingSlot {
private final UUID id;
private final VehicleType type;
private final double chargePerHour;
private boolean occupied;
private Vehicle currentVehicle;
public ParkingSlot(UUID id, VehicleType type, double chargePerHour) {
this.id = id;
this.type = type;
this.chargePerHour = chargePerHour;
}
public synchronized boolean canFit(Vehicle vehicle) {
return !occupied && vehicle.getType() == type;
}
public synchronized void park(Vehicle vehicle) {
this.currentVehicle = vehicle;
this.occupied = true;
}
public synchronized void free() {
this.currentVehicle = null;
this.occupied = false;
}
public VehicleType getType() {
return type;
}
public double getChargePerHour() {
return chargePerHour;
}
}
import java.util.List;
class ParkingFloor {
private final String id;
private final List<ParkingSlot> slots;
public ParkingFloor(String id, List<ParkingSlot> slots) {
this.id = id;
this.slots = slots;
}
public List<ParkingSlot> getSlots() {
return slots;
}
}
import java.util.List;
class ParkingLot {
private final List<ParkingFloor> floors;
public ParkingLot(List<ParkingFloor> floors) {
this.floors = floors;
}
public List<ParkingFloor> getFloors() {
return floors;
}
}
This hierarchical structure mirrors the real world while remaining flexible.
Parking Session
A critical insight in this design is modeling parking as a session. The Parking class represents the lifecycle of a vehicle inside the lotβfrom entry to exit.import java.time.Instant;
import java.util.UUID;
class Parking {
private final UUID id;
private final ParkingSlot slot;
private final Vehicle vehicle;
private final Instant entryTime;
private Instant exitTime;
public Parking(UUID id, ParkingSlot slot, Vehicle vehicle) {
this.id = id;
this.slot = slot;
this.vehicle = vehicle;
this.entryTime = Instant.now();
}
public void markExit() {
this.exitTime = Instant.now();
}
public Instant getEntryTime() {
return entryTime;
}
public Instant getExitTime() {
return exitTime;
}
public ParkingSlot getSlot() {
return slot;
}
}
The Ticket acts as a handle for this session and carries contextual information instead of being a mere identifier.
import java.time.Instant;
import java.util.UUID;
class Ticket {
private final UUID id;
private final Parking parking;
private final Instant issuedAt;
public Ticket(UUID id, Parking parking) {
this.id = id;
this.parking = parking;
this.issuedAt = Instant.now();
}
public Parking getParking() {
return parking;
}
}
This approach eliminates unnecessary lookups and keeps the system cohesive.
Pricing and Payment
To ensure flexibility, pricing is implemented using the Strategy Pattern. This allows different pricing models to be plugged in without changing core logic.import java.time.Instant;
interface PricingStrategy {
double calculate(Instant entry, Instant exit, double ratePerHour);
}
class HourlyPricingStrategy implements PricingStrategy {
@Override
public double calculate(Instant entry, Instant exit, double ratePerHour) {
long hours = Math.max(
1,
(exit.toEpochMilli() - entry.toEpochMilli()) / (1000 * 60 * 60)
);
return hours * ratePerHour;
}
}
Payment is modeled with a lifecycle state to support real-world scenarios such as failures and retries.
import java.util.UUID;
enum PaymentStatus {
PENDING,
SUCCESS,
FAILED
}
import java.util.UUID;
class Payment {
private final UUID id;
private final Parking parking;
private final double amount;
private PaymentStatus status;
public Payment(UUID id, Parking parking, double amount) {
this.id = id;
this.parking = parking;
this.amount = amount;
this.status = PaymentStatus.PENDING;
}
public void markSuccess() {
this.status = PaymentStatus.SUCCESS;
}
public Parking getParking() {
return parking;
}
}
Slot Allocation
The system performs slot allocation entirely at the database level to ensure both efficiency and correctness under concurrency. Instead of relying on in-memory structures or multiple queries, the allocation is handled using a single atomic database operation that both selects and reserves a slot in one step.This approach eliminates the need for caching, retry loops, or synchronization logic in the application layer. The database, optimized with proper indexing, efficiently finds an available slot and guarantees that no two concurrent requests can allocate the same slot.
class SlotManager {
private final ParkingSlotRepository repository;
public SlotManager(ParkingSlotRepository repository) {
this.repository = repository;
}
public ParkingSlot allocate(VehicleType type) {
return repository.allocateSlotAndReturn(type);
}
public void free(ParkingSlot slot) {
repository.markFree(slot.getId());
}
}
The allocation is implemented using a single query that locks, updates, and returns a slot:
WITH cte AS (
SELECT id
FROM parking_slot
WHERE type = :type
AND occupied = false
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE parking_slot s
SET occupied = true
FROM cte
WHERE s.id = cte.id
RETURNING s.*;
The query uses a CTE (WITH clause) to select one available slot of the given type and locks it using FOR UPDATE SKIP LOCKED, ensuring concurrent transactions skip already locked rows instead of waiting.
It then updates that same row to mark it as occupied and uses RETURNING to fetch the allocated slot in the same query, ensuring atomic allocation without race conditions.
To keep allocation fast even at scale, a composite index is used:
CREATE INDEX idx_slot_type_occupied ON parking_slot(type, occupied);
This allows the database to quickly locate available slots without scanning the entire table.
System Orchestration
The ParkingService connects all components and defines the system's behavior. This is where the real workflow lives.import java.util.UUID;
class ParkingService {
private final SlotManager slotManager;
private final PricingStrategy pricingStrategy;
public ParkingService(SlotManager slotManager, PricingStrategy pricingStrategy) {
this.slotManager = slotManager;
this.pricingStrategy = pricingStrategy;
}
public Ticket park(Vehicle vehicle) {
ParkingSlot slot = slotManager.allocate(vehicle.getType());
if (slot == null) {
throw new RuntimeException("No slots available");
}
slot.park(vehicle);
Parking parking = new Parking(UUID.randomUUID(), slot, vehicle);
return new Ticket(UUID.randomUUID(), parking);
}
public Payment exit(Ticket ticket) {
Parking parking = ticket.getParking();
parking.markExit();
double amount = pricingStrategy.calculate(
parking.getEntryTime(),
parking.getExitTime(),
parking.getSlot().getChargePerHour()
);
slotManager.free(parking.getSlot());
Payment payment = new Payment(UUID.randomUUID(), parking, amount);
return payment;
}
}
This layer ensures that all operations follow a consistent and controlled flow.
Conclusion
The design achieves single responsibility by ensuring each class has a well-defined role. It avoids duplication by keeping occupancy within ParkingSlot, and it ensures scalability through efficient data structures.Concurrency is handled using atomic database operations, and extensibility is achieved through abstractions like PricingStrategy.
Such a design not only performs well under load but also adapts gracefully to future requirements, which is the true hallmark of strong Low-Level Design.
Join the discussion