🅿️ Parking Lot System: A Production-Grade Low-Level Design in Java 21
Let's cut through the tutorial noise. Most "Parking Lot LLD" guides you'll find online give you a toy implementation that breaks the moment you add concurrency, a new vehicle type, or a pricing rule change. Today, we're building something that could actually survive a code review at a mid-sized tech company—and scale when your startup unexpectedly becomes the next ParkWhiz.
💡 Scope: This is a low-level design deep dive. We're focusing on object modeling, design patterns, and clean Java 21 code—not distributed systems, Redis caching, or Kubernetes deployment. Those come later.
🎯 Requirements (The "Real" Ones)
Before we write a single class, let's lock down what we're actually building:
✅ Support multiple vehicle types: Car, Motorcycle, Truck, EV
✅ Multi-floor parking with heterogeneous spot sizes (Compact, Regular, Large, EV-Charging)
✅ Ticket-based entry/exit with time-based pricing
✅ Strategy-based pricing: hourly, flat-rate, weekend surge, EV discount
✅ Thread-safe operations for concurrent entry/exit gates
✅ Extensible: Add new vehicle types or pricing rules without modifying core logic
✅ Observable: Log key events for audit and debugging
Notice what's not here: payment gateway integration, license plate recognition, mobile app APIs. Those are integration concerns, not core domain logic. Keep your bounded context tight.
🧱 Core Domain Model (Java 21 Style)
We'll leverage modern Java features: records for immutable DTOs, sealed interfaces for closed hierarchies, and enum with behavior for strategy selection.
Vehicle Hierarchy (Sealed for Safety)
// No more random subclasses appearing in production
public sealed interface Vehicle permits Car, Motorcycle, Truck, ElectricVehicle {
String getLicensePlate();
VehicleType getType();
enum VehicleType { CAR, MOTORCYCLE, TRUCK, ELECTRIC }
}
public record Car(String licensePlate) implements Vehicle {
@Override public VehicleType getType() { return VehicleType.CAR; }
}
// ... other implementations follow same pattern
Why sealed? Because in a parking system, you control the vehicle taxonomy. If someone needs a Bus, that's a business decision that should trigger a deliberate code change—not a runtime surprise.
Parking Spot Strategy
public enum SpotSize { COMPACT, REGULAR, LARGE, EV_CHARGING }
public final class ParkingSpot {
private final String spotId;
private final SpotSize size;
private final int floor;
private volatile Vehicle parkedVehicle; // thread-safe visibility
public boolean isAvailable() { return parkedVehicle == null; }
public synchronized boolean park(Vehicle vehicle) {
if (!isAvailable() || !canAccommodate(vehicle)) return false;
this.parkedVehicle = vehicle;
return true;
}
public synchronized Vehicle vacate() {
var vehicle = parkedVehicle;
parkedVehicle = null;
return vehicle;
}
private boolean canAccommodate(Vehicle v) {
return switch (v.getType()) {
case MOTORCYCLE -> true; // fits anywhere
case CAR -> size != SpotSize.COMPACT;
case TRUCK -> size == SpotSize.LARGE;
case ELECTRIC -> size == SpotSize.EV_CHARGING || size == SpotSize.LARGE;
};
}
}
Key decisions:
synchronizedmethods for simple thread safety (we'll revisit withReentrantLockif contention profiling shows bottlenecks)volatilefor lock-free reads of availability status- Strategy embedded in
canAccommodate(): vehicle-to-spot matching logic lives with the spot, not scattered in services
💰 Pricing: Strategy Pattern Done Right
Hardcoding pricing rules is technical debt with a countdown timer. Let's use the Strategy pattern—but make it testable and configurable.
@FunctionalInterface
public interface PricingStrategy {
BigDecimal calculateFee(Duration duration, Vehicle vehicle);
}
// Example: Hourly rate with EV discount
public record HourlyPricingStrategy(
BigDecimal baseRatePerHour,
BigDecimal evDiscountFactor // e.g., 0.85 = 15% off
) implements PricingStrategy {
@Override
public BigDecimal calculateFee(Duration duration, Vehicle vehicle) {
var hours = Math.ceil(duration.toMinutes() / 60.0);
var base = baseRatePerHour.multiply(BigDecimal.valueOf(hours));
return (vehicle instanceof ElectricVehicle)
? base.multiply(evDiscountFactor)
: base;
}
}
// Strategy resolver: could be backed by config service in production
public class PricingStrategyResolver {
private final Map<Vehicle.VehicleType, PricingStrategy> strategies;
public PricingStrategyResolver(Map<Vehicle.VehicleType, PricingStrategy> strategies) {
this.strategies = Map.copyOf(strategies); // immutable
}
public PricingStrategy resolve(Vehicle vehicle) {
return strategies.getOrDefault(vehicle.getType(),
new HourlyPricingStrategy(BigDecimal.TEN, BigDecimal.ONE));
}
}
Pro tip: In production, load these strategies from a feature-flag service or config map. That way, marketing can run weekend promotions without a deployment.
🎫 Ticket Management: Immutable by Default
public record ParkingTicket(
String ticketId,
Vehicle vehicle,
ParkingSpot spot,
Instant entryTime,
Instant exitTime, // null while parked
boolean paid
) {
public ParkingTicket markExit(Instant exitTime) {
return new ParkingTicket(ticketId, vehicle, spot, entryTime, exitTime, false);
}
public ParkingTicket markPaid() {
return new ParkingTicket(ticketId, vehicle, spot, entryTime, exitTime, true);
}
}
Records give us immutability, value-based equality, and concise syntax. When a ticket state changes, we replace it—not mutate. This eliminates a whole class of concurrency bugs.
🏗️ The ParkingLot Orchestrator (Singleton, Done Safely)
Yes, we need a singleton—but let's do it right for Java 21:
public final class ParkingLot {
private static final class Holder {
static final ParkingLot INSTANCE = new ParkingLot();
}
public static ParkingLot getInstance() {
return Holder.INSTANCE; // lazy, thread-safe, no synchronization overhead
}
private final ConcurrentHashMap<String, ParkingFloor> floors = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, ParkingTicket> activeTickets = new ConcurrentHashMap<>();
private final PricingStrategyResolver pricingResolver;
// constructor injection for testability
private ParkingLot(PricingStrategyResolver pricingResolver) {
this.pricingResolver = Objects.requireNonNull(pricingResolver);
}
public ParkingTicket enterVehicle(Vehicle vehicle, Instant entryTime) {
var spot = findAvailableSpot(vehicle)
.orElseThrow(() -> new ParkingLotFullException(vehicle.getType()));
if (!spot.park(vehicle)) {
throw new IllegalStateException("Race condition: spot taken");
}
var ticket = new ParkingTicket(
UUID.randomUUID().toString(),
vehicle, spot, entryTime, null, false
);
activeTickets.put(ticket.ticketId(), ticket);
return ticket;
}
// ... exitVehicle(), findAvailableSpot(), etc.
}
Why ConcurrentHashMap? Because entry/exit gates operate concurrently. Why not synchronized on the whole method? Because we want fine-grained locking—only the spot being parked needs synchronization.
🧵 Concurrency: Virtual Threads for High-Throughput Gates (Java 21+)
If you're running a busy lot with 20+ entry/exit points, traditional thread-per-request can become a bottleneck. Enter virtual threads:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Each gate handler runs on a virtual thread
for (var gate : entryGates) {
executor.submit(() -> gate.processEntry(vehicleQueue));
}
}
Virtual threads let you scale to thousands of concurrent gate operations without tuning thread pool sizes. Just remember: your business logic must still be thread-safe (which our synchronized spot methods already ensure).
🧪 Testing Strategy That Doesn't Suck
@Test
void shouldAssignCompactSpotToMotorcycle() {
var lot = ParkingLot.builder()
.withFloor(new ParkingFloor(1, List.of(
new ParkingSpot("C1", SpotSize.COMPACT, 1)
)))
.build();
var vehicle = new Motorcycle("MH01AB1234");
var ticket = lot.enterVehicle(vehicle, Instant.now());
assertThat(ticket.spot().size()).isEqualTo(SpotSize.COMPACT);
}
@Test
void shouldRejectTruckInCompactSpot() {
var spot = new ParkingSpot("C1", SpotSize.COMPACT, 1);
var truck = new Truck("DL01XY9999");
assertThat(spot.park(truck)).isFalse();
}
Test the behavior, not the implementation. Use builders for complex object setup. And always test failure cases—full lot, invalid vehicle/spot combos, concurrent access.
🚀 Production Hardening Checklist
Before you merge this to main:
- Add structured logging (SLF4J + JSON layout) for audit trails
- Wrap
ParkingLotoperations in circuit breakers if integrating with external sensors - Add metrics:
parking.spot.occupancy,parking.entry.latency - Externalize pricing strategies to config (Spring Cloud Config, etc.)
- Add integration tests with
@SpringBootTestif using Spring Boot - Profile contention on
ParkingSpot—considerReentrantLockifsynchronizedshows bottlenecks
🔚 Final Thoughts
A Parking Lot LLD isn't about writing 50 classes. It's about:
- Modeling the domain accurately (vehicles, spots, tickets as first-class citizens)
- Applying patterns intentionally (Strategy for pricing, Singleton for orchestration, not because "interviews ask for it")
- Writing concurrent code defensively (volatile, synchronized, or locks—choose based on profiling)
- Designing for change (sealed hierarchies, strategy injection, immutable records)
The code here is a foundation. Your business will add reservation systems, dynamic pricing, IoT sensors. If your core domain is clean, those extensions won't require a rewrite.
🛠️ Repo: github.com/yourname/parking-lot-java21 (imaginary, but you should make it real)
📚 Further Reading:
- Implement LLD for Parking Lot: Code Walkthrough
- Designing a Scalable Parking Lot System — Medium
- Java 21 Virtual Threads in Practice
Got a tricky LLD problem you want me to break down next? Drop it in the comments. I live for these. ☕️