Parking lot system LLD - Java Inspires

🅿️ 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:

  • synchronized methods for simple thread safety (we'll revisit with ReentrantLock if contention profiling shows bottlenecks)
  • volatile for 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 ParkingLot operations 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 @SpringBootTest if using Spring Boot
  • Profile contention on ParkingSpot—consider ReentrantLock if synchronized shows bottlenecks

🔚 Final Thoughts

A Parking Lot LLD isn't about writing 50 classes. It's about:

  1. Modeling the domain accurately (vehicles, spots, tickets as first-class citizens)
  2. Applying patterns intentionally (Strategy for pricing, Singleton for orchestration, not because "interviews ask for it")
  3. Writing concurrent code defensively (volatile, synchronized, or locks—choose based on profiling)
  4. 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. ☕️

Disclaimer: This design assumes a single-node deployment. For multi-node, you'd need distributed locking (Redis/ZooKeeper) and event sourcing—but that's a system design post, not LLD. Stay tuned.

Previous Post Next Post

Contact Form