Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 20

    LLD Case Study: Parking Lot

    A parking garage looks small enough to design in one interview hour, and large enough to lose money when two gates sell the same stall. TechLearningPro runs a campus garage: two en

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    LLD Case Study: Parking Lot

    Advanced System Design & Software Architecture · Lesson 20 · Low-Level Design and Design Patterns

    Lesson Overview

    A parking garage looks small enough to design in one interview hour, and large enough to lose money when two gates sell the same stall. TechLearningPro runs a campus garage: two entrances, one exit, four floors, spots for motorcycles, cars, and vans, a handful of stalls with chargers, and a pricing board that finance changes without a deploy of the gate firmware. The first version stored "next free spot" as an integer on a singleton. Two entry lanes called it, both received stall 12, and two vehicles were waved in. The integer increment was not the bug. The bug was a check and an update that were not one critical section, plus a design that could not say which vehicle types fit which stalls.

    This lesson is a full low-level design of that garage. It walks from requirements to classes, to the sequences for entry and exit, to Java that allocates under a lock and prices through a strategy, to tests that fail if two threads take one spot, and to the trade-offs you should say out loud: one process versus a database row lock, a coarse lock versus a striped lock, and a strategy versus a switch. The gate hardware is an adapter. It calls ParkVehicle. It does not contain the fit rules. Spring's container, from lesson 19, can inject the allocation policy and the pricing policy. The container cannot make allocate atomic. That is the inventory's job, or the database's.

    The patterns are the ones you already have. Allocation and pricing are Strategies from Behavioral Design Patterns. The ticket store is a repository from lesson 18. The domain types do not import Spring, which is the rule from Clean Architecture and Hexagonal Architecture and Ports & Adapters. No class here pretends to be a gate-vendor SDK. Clock is java.time.Clock. Money is the course's value type. Locks are java.util.concurrent.locks.ReentrantLock.

    Learning Objectives

    By the end of this lesson you will be able to:

    1. Turn a garage brief into fit rules, ticket states, and pricing rules that a test can enforce.
    2. Assign responsibilities to Spot, Ticket, SpotInventory, allocation policies, and pricing policies without a god ParkingLot method.
    3. Implement allocate so selection and occupancy update are one critical section, and explain why a lock around only the update fails.
    4. Swap hourly and flat pricing without editing entry.
    5. State what the in-memory lock does not guarantee once two application instances share the garage.
    6. Write a concurrency test that uses a real thread pool and asserts a single winner.
    7. Review an LLD for missing exit paths: abandoned tickets, out-of-service stalls, and a price that changes while the vehicle is inside.

    Prerequisites

    • Lessons 17 through 19: strategy, repository, constructor injection, and a fake that honors the contract under threads.
    • Java 21: sealed interfaces, records, ReentrantLock, ExecutorService, CountDownLatch. JUnit 5 assertEquals and assertThrows.
    • Lesson 4's open-closed principle, because a new vehicle type should be a type and a fit rule, not a new branch copied into the gate controller and the signboard.
    • You do not need a prior parking-domain model. The fit rules below are product decisions for this case study, written so they can be changed in one place.

    Detailed Core Concepts

    What low-level design must pin down

    High-level design would say "a garage service with a database." Low-level design names the types, the invariants, the sequences, and the failure a concurrent call produces. For this garage the invariants are:

    • A stall is free, occupied, or out of service. An occupied stall has exactly one open ticket. A free stall has none.
    • A vehicle is admitted only to a stall that fits it, according to the published fit rule.
    • Entry returns one ticket or a typed "full" or "no fit" result. It does not return a stall id that another entry also received.
    • The price is computed at exit from the ticket's entry time, the exit time, and the pricing policy that was named on the ticket at entry. A later change to the default policy does not reprice vehicles already inside.
    • Paying an already closed ticket returns the original receipt. It does not charge again.

    Those sentences are the design. The classes exist to make them local.

    Spots, vehicles, and fit

    A stall has an id, a floor, a bay class, and whether it has a charger. Bay class is an enum: MOTORCYCLE, COMPACT, LARGE. The charger is a capability, not a fourth class that forgets how large the bay is. An electric car still needs a compact or large bay; the charger is additional.

    Vehicles are a sealed type: Motorcycle, Car, Van. Each carries a plate and, for Car and Van, whether the driver wants a charger. The fit rule for this case study:

    • A motorcycle fits every bay class. It does not need a charger. Allocating a large bay to a motorcycle is legal and usually undesirable; the allocation policy, not the fit rule, prefers the smallest fitting bay.
    • A car fits COMPACT and LARGE. It does not fit MOTORCYCLE.
    • A van fits LARGE only.
    • A vehicle that wants a charger fits only stalls that have one, unless the caller sets chargerRequired to false. An electric car that can charge elsewhere may take a stall without a charger. The ticket stores the choice. The fit rule does not guess from a vehicle-type name "EV," because the course will not invent an electric-vehicle taxonomy the product has not written.

    Spot.fits(Vehicle) implements that rule and nothing about price or tickets. If fit lives in the allocator, the signboard's "stalls you can use" count drifts from admission. One method, called by both.

    Tickets and money

    A ticket is created when a stall is taken: ticket id, spot id, plate, entry instant, pricing code, state OPEN. Exit quotes a price, records payment, and sets CLOSED, releasing the stall. This case study does not split "paid" and "exited" into a long gap where the stall stays occupied after payment while the vehicle looks for the exit; the exit lane computes, collects, and releases in one use case. If the site later has pay stations in the lobby, the state gains PAID and a time limit before the stall is released by a sweep. That change is a new state, lesson 17's State pattern, added when the lobby pay station exists. Adding it now, with no caller, is the speculative abstraction lesson 4 told you to refuse.

    The pricing code is copied onto the ticket at entry. HourlyPricing and EveningFlatPricing implement ParkingPricing. Exit loads the code and selects the policy. It does not call "whatever the garage default is today." Finance can change the default for new entries without rewriting open tickets. That is the snapshot rule from lesson 17's early-bird window.

    HourlyPricing charges ceil(minutes / 60) * rate with a cap per ticket stored on the policy. Partial hours round up because that is the published board, not because floating point said so. The calculation uses integer minutes and a Money type. It does not use double for currency. EveningFlatPricing charges a flat Money when entry is inside the window and hourly otherwise, still using the snapshotted code so the window definition is the one on the policy version named by the ticket. If windows themselves must be historical, the ticket stores the rate and the flat amount, not only a code. This design stores the code and versions policies immutably: a change is a new code, HOURLY_2026_A. Editing the rate inside HOURLY_2026_A reprices parked cars. The operational rule is: new rate, new code, new default for entries.

    Allocation is a strategy under a critical section

    AllocationPolicy.choose(Vehicle, List<Spot>) returns the free fitting stall it prefers, or empty. NearestEntrance prefers the lowest floor, then the smallest fitting bay, then the stall id for stability. ChargerFirst prefers a charger stall when the vehicle wants one, then falls back to NearestEntrance rules. The policy is a pure function of the list it is given. It does not mutate the stall. Mutation is the inventory's job, inside the lock, after choose returns.

    The lock covers choose and markOccupied. If choose runs outside the lock, two threads can select stall 12, then both enter the lock and both mark it, unless the mark is a compare-and-set that the loser handles by choosing again. A lock only around markOccupied, after a selection made on a stale list, does not repair the stale selection. Either the whole select-and-mark is inside one lock, or markOccupied fails when the stall is no longer free and the caller loops. This lesson uses one ReentrantLock on the inventory for the in-memory garage. Hundreds of stalls and two gates do not need stripes. A stripe per stall still requires the "mark fails if not free" loop, because two threads can choose the same stall before either stripe is taken. The coarse lock is easier to prove. The reopen condition is a measured wait at the gate, not an aesthetic preference for fine-grained locks.

    tryLock with a short timeout is how the entry lane fails rather than queueing behind a stuck thread. If the lock is not acquired, the use case returns "try again" to the gate. It does not allocate. A blocked lock() with no timeout turns a stuck exit into a stuck entrance.

    Concurrency across processes

    The ReentrantLock is local to one JVM. Two instances of the service, both with a copy of the stall list, will double-allocate. The production adapter replaces the list with rows and allocates with a conditional update:

    sql
    update spot
    set status = 'OCCUPIED', ticket_id = :ticket
    where id = :id and status = 'FREE'

    An update count of zero means someone else took it; the allocator picks another candidate and retries a bounded number of times. The transaction is the one from lesson 18: short, no HTTP to a payment acquirer inside it. The in-memory lock remains the right design for a single-node campus deployment and for the unit test. The repository port SpotInventory hides which one you run. The test for the JDBC adapter is the update-count test. The test for the in-memory class is the two-thread test. They protect different guarantees. Lesson 19's warning applies: a fake that ignores the race will make the handler look correct.

    Tickets as a repository

    TicketRepository.save(Ticket) and find(TicketId) follow lesson 18. The open ticket's stall id is unique: a partial unique index on spot_id where state is OPEN is the database backstop even if the application lock has a bug. The in-memory repository enforces the same uniqueness in save. Exit loads the ticket, computes price, marks CLOSED, and frees the stall in one unit of work. A crash after charging the driver and before freeing the stall is the interesting window. This case study collects payment in-process as a Payment record the exit operator confirms, not as a card network call. Card capture is lesson 22. If you later call a gateway at the exit, you authorize outside the stall transaction, then commit the ticket and the stall release together, and you keep an idempotency key on the exit command so a retry does not charge twice and does not free the stall twice.

    What this design refuses

    It is not a 3D map of the garage and not a pathfinding problem. It does not assign a specific walking route. It does not talk to a camera SDK. A plate is a string the gate adapter already read. It does not store card numbers. It does not use synchronized on the public service singleton as a substitute for a lock around the inventory's data structure; synchronizing the Spring bean serializes every method, including availability reads and pricing quotes, and still fails if a second instance exists. The lock belongs on the data the race corrupts.

    Architecture and Design Diagrams

    mermaid
    classDiagram
    class Vehicle {
    <<sealed>>
    +plate() String
    +wantsCharger() boolean
    }
    class Spot {
    +id() SpotId
    +bay() BayClass
    +hasCharger() boolean
    +fits(Vehicle) boolean
    +status() SpotStatus
    }
    class AllocationPolicy {
    <<interface>>
    +choose(Vehicle, List) Optional
    }
    class ParkingPricing {
    <<interface>>
    +code() String
    +quote(Ticket, Instant) Money
    }
    class SpotInventory {
    +allocate(Vehicle, AllocationPolicy) Spot
    +release(SpotId) void
    }
    class Ticket {
    +state() TicketState
    +pricingCode() String
    +close(Instant, Money) void
    }
    class ParkVehicle {
    +handle(EnterCommand) EnterReceipt
    }
    Vehicle <|-- Car
    Vehicle <|-- Motorcycle
    Vehicle <|-- Van
    ParkVehicle --> SpotInventory
    ParkVehicle --> Ticket
    SpotInventory --> AllocationPolicy
    SpotInventory --> Spot

    Entry sequence. The policy does not mark the stall. The inventory does, inside the lock.

    mermaid
    sequenceDiagram
    participant Gate
    participant Park as ParkVehicle
    participant Inv as SpotInventory
    participant Policy as AllocationPolicy
    participant Tickets as TicketRepository
    Gate->>Park: EnterCommand plate, vehicle, pricingCode
    Park->>Inv: allocate
    Inv->>Inv: lock
    Inv->>Policy: choose among free spots
    Policy-->>Inv: Spot or empty
    Inv->>Inv: mark OCCUPIED
    Inv->>Inv: unlock
    alt No spot
    Park-->>Gate: GarageFull
    else Spot taken
    Park->>Tickets: save OPEN ticket with pricing code
    Park-->>Gate: ticket id and spot id
    end

    If save fails after allocate, the handler releases the stall in a finally path before returning the error. Otherwise the stall stays occupied with no ticket, which is a leak the availability board will show as a ghost car. The release and the failed save are the compensation for "we do not have one distributed transaction with a piece of hardware." In the JDBC adapter, the conditional spot update and the ticket insert share one database transaction, so compensation is rollback instead of an explicit release. The in-memory design needs the explicit release because the lock is not a transaction manager.

    Exit:

    mermaid
    sequenceDiagram
    participant Gate
    participant Exit as ExitVehicle
    participant Tickets as TicketRepository
    participant Price as ParkingPricing
    participant Inv as SpotInventory
    Gate->>Exit: ExitCommand ticketId
    Exit->>Tickets: find OPEN ticket
    Exit->>Price: quote(ticket, now)
    Price-->>Exit: Money
    Exit->>Tickets: close ticket
    Exit->>Inv: release spot
    Exit-->>Gate: receipt

    Close and release share a unit of work in the JDBC adapter. A second exit with the same ticket id finds CLOSED and returns the stored receipt without releasing a stall that is already free.

    Technical Explanation

    Domain types

    java
    public enum BayClass { MOTORCYCLE, COMPACT, LARGE }
    public enum SpotStatus { FREE, OCCUPIED, OUT_OF_SERVICE }
    public enum TicketState { OPEN, CLOSED }
    public sealed interface Vehicle permits Motorcycle, Car, Van {
    String plate();
    boolean wantsCharger();
    }
    public record Car(String plate, boolean wantsCharger) implements Vehicle {}
    public record Motorcycle(String plate) implements Vehicle {
    @Override public boolean wantsCharger() { return false; }
    }
    public record Van(String plate, boolean wantsCharger) implements Vehicle {}

    Fit:

    java
    public final class Spot {
    private final SpotId id;
    private final int floor;
    private final BayClass bay;
    private final boolean charger;
    private SpotStatus status;
    public boolean fits(Vehicle vehicle) {
    if (status != SpotStatus.FREE) {
    return false;
    }
    if (vehicle.wantsCharger() && !charger) {
    return false;
    }
    return switch (vehicle) {
    case Motorcycle m -> true;
    case Car c -> bay != BayClass.MOTORCYCLE;
    case Van v -> bay == BayClass.LARGE;
    };
    }
    void markOccupied() {
    if (status != SpotStatus.FREE) {
    throw new SpotNotFree(id);
    }
    status = SpotStatus.OCCUPIED;
    }
    void markFree() {
    status = SpotStatus.FREE;
    }
    }

    markOccupied repeats the free check so a policy bug that returns an occupied stall fails closed. fits also requires free, so a correct policy never sees occupied stalls if the inventory filters first. Defense in depth is cheap here. OUT_OF_SERVICE stalls fail fits because their status is not FREE. Taking a stall out of service is an admin command that refuses when the stall is occupied; you do not vanish a car.

    Inventory and lock

    java
    public final class InMemorySpotInventory {
    private final ReentrantLock lock = new ReentrantLock();
    private final List<Spot> spots;
    public InMemorySpotInventory(List<Spot> spots) {
    this.spots = spots;
    }
    public Spot allocate(Vehicle vehicle, AllocationPolicy policy) {
    if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
    throw new AllocationBusy();
    }
    try {
    List<Spot> free = spots.stream().filter(s -> s.fits(vehicle)).toList();
    Spot chosen = policy.choose(vehicle, free).orElseThrow(GarageFull::new);
    chosen.markOccupied();
    return chosen;
    } finally {
    lock.unlock();
    }
    }
    public void release(SpotId id) {
    lock.lock();
    try {
    spot(id).markFree();
    } finally {
    lock.unlock();
    }
    }
    }

    tryLock throws the checked InterruptedException. The method signature in the real class wraps it as an unchecked AllocationBusy or restores the interrupt flag and throws. Do not swallow the interrupt. choose runs inside the lock, so the policy must be fast and must not call the database or the payment gateway. A policy that does I/O holds every gate. The policies in this lesson sort an in-memory list.

    NearestEntrance:

    java
    public final class NearestEntrance implements AllocationPolicy {
    @Override
    public Optional<Spot> choose(Vehicle vehicle, List<Spot> fittingFree) {
    return fittingFree.stream()
    .min(Comparator.comparingInt(Spot::floor)
    .thenComparingInt(s -> s.bay().ordinal())
    .thenComparing(s -> s.id().value()));
    }
    }

    Bay ordinal works only because the enum declaration order is smallest to largest and that order is the product rule. A comment on the enum says so. If someone reorders the enum for aesthetics, allocation changes. A rank() method on BayClass is sturdier. The case study uses rank() in the repository version of this file's code review checklist; the snippet shows the risk on purpose so the review question has somewhere to land. Prefer:

    java
    .thenComparingInt(s -> s.bay().fitRank())

    with MOTORCYCLE rank 0, COMPACT rank 1, LARGE rank 2, independent of declaration order.

    Pricing

    java
    public interface ParkingPricing {
    String code();
    Money quote(Ticket ticket, Instant exitAt);
    }
    public final class HourlyPricing implements ParkingPricing {
    private final Money perHour;
    private final Money cap;
    private final String code;
    public HourlyPricing(String code, Money perHour, Money cap) {
    this.code = code;
    this.perHour = perHour;
    this.cap = cap;
    }
    @Override
    public String code() { return code; }
    @Override
    public Money quote(Ticket ticket, Instant exitAt) {
    long minutes = Duration.between(ticket.enteredAt(), exitAt).toMinutes();
    if (minutes < 0) {
    throw new ExitBeforeEntry(ticket.id());
    }
    long hours = Math.max(1, (minutes + 59) / 60);
    Money raw = perHour.times(hours);
    return raw.greaterThan(cap) ? cap : raw;
    }
    }

    times and greaterThan live on Money and reject mixed currencies. A minimum of one hour matches a board that says "one hour minimum." If the product wants "first 15 minutes free," that is a different policy class, not a boolean on HourlyPricing that every caller must remember. Open-closed, lesson 4: a new board is a new ParkingPricing.

    The selector is lesson 17's map of code to policy, failing on duplicates and on unknown codes. The ticket's code is the key. Exit does not take a pricing code from the client. A client-supplied code is how a driver picks the flat rate at exit after parking all day.

    Handlers

    java
    public final class ParkVehicle {
    private final InMemorySpotInventory inventory;
    private final AllocationPolicy allocation;
    private final TicketRepository tickets;
    private final String pricingCode;
    private final Clock clock;
    public EnterReceipt handle(EnterCommand command) {
    Spot spot = inventory.allocate(command.vehicle(), allocation);
    Ticket ticket = Ticket.open(
    TicketId.random(), spot.id(), command.vehicle().plate(),
    clock.instant(), pricingCode);
    try {
    tickets.save(ticket);
    } catch (RuntimeException ex) {
    inventory.release(spot.id());
    throw ex;
    }
    return new EnterReceipt(ticket.id(), spot.id(), spot.floor());
    }
    }

    pricingCode is the garage default injected from configuration, lesson 19's @ConfigurationProperties mapped to a string the selector knows. Per-entry overrides, if the booth agent may choose, are a field on EnterCommand validated against the selector. They are still snapshotted onto the ticket.

    Exit loads by id, rejects a plate mismatch if the gate reads a plate and it differs, quotes, then persists close and release. Plate mismatch is a domain exception the gate maps to "attendant required." It is not a silent reassignment of the ticket.

    Idempotent exit

    ExitCommand carries an idempotency key for the booth. The receipt is stored under that key and under the ticket id. A timeout retry returns the same money and does not call release again. A second release would mark a stall free that a new car might already occupy if you had released and a new entry had taken it — which is why the state check is "if already CLOSED, return the stored receipt" and release runs only on the transition from OPEN to CLOSED.

    Practical Implementation or Design Walkthrough

    Requirements

    Functional:

    1. Admit a motorcycle, car, or van to a fitting free stall, or refuse with GarageFull when none fit.
    2. Prefer lower floors and smaller fitting bays for the default policy.
    3. When the vehicle wants a charger, prefer a fitting stall with a charger, then a fitting stall without one only if wantsCharger is false. If true and none have a charger, return GarageFull even if compact stalls remain.
    4. Issue an OPEN ticket with entry time and pricing code.
    5. On exit, charge by the snapshotted policy, close the ticket, free the stall.
    6. A repeated exit returns the original receipt and does not free a stall twice.
    7. An attendant can mark a free stall OUT_OF_SERVICE and later return it to FREE. Occupied stalls refuse the mark.

    Non-functional:

    1. Two concurrent entries cannot receive the same stall, in one process.
    2. Allocation holds no lock during I/O to a payer.
    3. Pricing uses integer money.
    4. Domain types do not import Spring or a gate vendor API.
    5. Availability reads may run without blocking entry for long; they take the same lock only to copy counts, and they do not allocate.

    Assumptions: one campus, one process for the first release, plates are unique enough for a mismatch check but are not a security boundary, the booth operator is authenticated by the gate adapter, and the clock is injected.

    Class responsibilities

    TypeResponsibility
    VehiclePlate and charger need. No price, no stall.
    SpotBay, charger, status, fits, mark free or occupied.
    AllocationPolicyChoose among stalls already known to fit. No mutation.
    InMemorySpotInventoryCritical section for allocate and release.
    ParkingPricingMoney for a ticket and an exit instant.
    TicketIdentity, snapshot, OPEN to CLOSED.
    TicketRepositoryPersist and enforce one open ticket per stall.
    ParkVehicle, ExitVehicleUse cases.
    Gate controllerHTTP or a local socket the booth already speaks. Maps exceptions to attendant messages.

    The controller is an adapter. The interview drawing that puts main inside ParkingLot and calls new Car() from a scanner is a demo harness, not the design. Keep the harness in bootstrap if you need one.

    Tests

    Fit tests: motorcycle fits a motorcycle bay; car does not; van does not fit compact; car with wantsCharger true does not fit a compact stall without a charger; out-of-service stall does not fit.

    Pricing tests: 61 minutes at a fixed clock is two hours; 60 minutes is one hour; the cap applies; exit before entry throws; a ticket stamped HOURLY_2026_A does not pick up HOURLY_2026_B when the default changes.

    Concurrency:

    java
    @Test
    void twoEntriesOneStall() throws Exception {
    Spot spot = new Spot(SpotId.of("A1"), 1, BayClass.COMPACT, false);
    InMemorySpotInventory inventory = new InMemorySpotInventory(List.of(spot));
    AllocationPolicy policy = new NearestEntrance();
    ExecutorService pool = Executors.newFixedThreadPool(2);
    CountDownLatch start = new CountDownLatch(1);
    AtomicInteger wins = new AtomicInteger();
    AtomicInteger full = new AtomicInteger();
    Callable<Void> attempt = () -> {
    start.await();
    try {
    inventory.allocate(new Car("P" + Thread.currentThread().threadId(), false), policy);
    wins.incrementAndGet();
    } catch (GarageFull ex) {
    full.incrementAndGet();
    }
    return null;
    };
    var first = pool.submit(attempt);
    var second = pool.submit(attempt);
    start.countDown();
    first.get(2, TimeUnit.SECONDS);
    second.get(2, TimeUnit.SECONDS);
    pool.shutdown();
    assertEquals(1, wins.get());
    assertEquals(1, full.get());
    assertEquals(SpotStatus.OCCUPIED, spot.status());
    }

    threadId is Java 19+. If you are on an older language level, use getId(). The course baseline is Java 21. The latch stops one thread from finishing before the other starts, which is a weak test if the critical section is tiny and they still do not overlap. Run the assertion in a loop of several hundred iterations in the real suite so a broken lock fails often. A single iteration can pass by accident. Say that in review. The snippet shows the shape; the suite repeats it.

    A second test releases and allocates again and expects success. A third test fails tickets.save with a repository that throws, and expects the stall to be FREE afterward.

    JDBC test, when you build the adapter: two transactions run the conditional update; one update count is 1; the ticket insert for the loser never commits.

    Trade-offs

    • In-memory lock versus conditional SQL. One process, in-memory is simpler and the two-thread test is honest. Two processes, the lock is theater. Choose SQL when you run more than one instance. Do not run two instances "for availability" with the in-memory inventory.
    • Coarse lock versus stripes. Coarse is correct and short if policies stay in memory. Stripes need a retry loop. Add stripes after a measurement shows the lock wait at the gate.
    • Strategy versus if on vehicle. Fit is a sealed switch, which is closed over the vehicle hierarchy and reviewed by the compiler. Allocation preference and price are strategies because finance and operations change them. Putting price in the sealed switch would recompile admission to change a rate.
    • Snapshot code versus snapshot amounts. Codes are enough when policy objects are immutable and versioned. Amounts on the ticket survive a process that cannot load an old policy class. If you delete HOURLY_2026_A from the selector, open tickets break. Keep old policies loaded until their open tickets are gone, or store the rate on the ticket. Storing the rate is the more durable snapshot and duplicates data. For a campus garage, storing Money perHour and Money cap on the ticket is the better operational choice. The code remains as a label. This walkthrough started with a code to show the strategy selector; the review move is to copy the numbers onto the ticket at entry so exit does not depend on a live registry of historical classes. Do that copy. The selector is for entry. Exit reads the ticket's rate. The paragraphs above that say exit looks up the code are the first design. The stronger design, adopted here as the one to implement, is: entry resolves the code to amounts, stores the amounts, and exit uses only the ticket. An unknown code then fails at entry, when the driver is still at the gate, not at exit, when they are trying to leave.

    That last trade-off is the one teams miss. A strategy selected at exit from a mutable singleton is a pricing incident. A strategy used at entry to freeze numbers is a strategy used at the right time.

    Enterprise Real-World Scenarios

    Two gates on a campus launch week

    Commencement doubles arrivals for three hours. Both lanes run the same ParkVehicle. The in-memory inventory is the critical section. Operators need an availability count that is allowed to be a second stale, not a second double-booked. availability() takes the lock, copies three integers, and releases. It does not call the sign vendor. A separate adapter polls availability and paints the sign. If the poll holds the lock while it waits on a serial port, the gates stall. The adapter copies counts, drops the lock, then writes to the sign. Lesson 18's "no I/O inside the transaction" is the same rule with a lock instead of a database transaction.

    When the college adds a second building, the inventory moves to PostgreSQL and the conditional update. The use case ParkVehicle stays. The Spring configuration from lesson 19 swaps InMemorySpotInventory for JdbcSpotInventory behind one interface SpotInventory. That swap is the modifiability scenario. If ParkVehicle constructed new InMemorySpotInventory internally, the second building would fork the use case.

    Attendants mark stalls out of service from a phone. That command takes the same lock or the same conditional update (where status = 'FREE'). A stall that became occupied between the attendant's screen load and the submit returns a conflict, lesson 18's optimistic story, here expressed as update count zero. The screen reloads. It does not force the mark.

    A municipal garage with a lobby pay station

    The city pays before the driver walks to the car. The ticket gains state PAID, with paidUntil equal to the payment time plus a grace period. Exit releases the stall only when the ticket is PAID and now is before paidUntil, or when the driver pays the difference at the lane. The stall stays occupied until exit, so allocation does not hand the stall to someone else while the owner is walking. A sweep job closes tickets that are PAID and abandoned past a longer threshold and frees the stall; the sweep is a driving adapter, lesson 6, calling a ReleaseAbandoned use case, not a SQL script that sets FREE and leaves the ticket OPEN. The script is the bug that creates two open realities.

    Pricing strategies multiply: resident monthly (zero at exit if a permit repository says the plate is covered), event flat, hourly. Permit lookup is a port. It runs at exit before the charge, outside any long lock: read permit, then take the inventory lock only to release. A permit store outage has a product decision you must write down. This design fails closed at the lane (attendant) rather than opening the gate for free. Failing open is a financial leak. Failing closed is a queue. The city can choose the queue. Record it. Do not let a catch return Money.zero().

    Best Practices

    • Write fit rules and ticket states before classes. Change them in one type.
    • Keep allocation policies pure. Mutate stalls in one inventory method under the lock or the conditional update.
    • Snapshot price inputs at entry. Exit does not consult a mutable default.
    • Use integer Money. Round partial hours in a way the board states, and test the boundary minute.
    • Compensate a failed ticket insert by releasing the stall, or do both writes in one database transaction.
    • Make exit idempotent on ticket state. Release only on the open-to-closed transition.
    • Bound lock wait. Do not hold the lock across the sign, the permit service, or a card call.
    • Inject Clock and policies through the constructor.
    • Repeat the concurrency test. One green run is not evidence.
    • Backstop with a uniqueness constraint: one open ticket per stall.
    • Treat the gate UI as an adapter that maps GarageFull and plate mismatch. It does not implement fits.

    Common Mistakes and Anti-Patterns

    Check-then-act outside the lock. The code reads free stalls, drops the lock, then marks. Two gates take one stall. The mark must be in the same critical section as the choice, or the mark must fail and the choice must retry.

    synchronized on the service, I/O inside the synchronized method. Pricing calls a remote tax service while holding the monitor. Entrances queue behind tax. Even the coarse lock is only safe because quote is local and runs at exit outside allocate.

    Price looked up from a mutable singleton at exit. Finance edits the hourly rate object. Cars that entered under the old board pay the new one. Freeze amounts on the ticket.

    Fit logic copied into the controller and the inventory. The sign says cars may use motorcycle bays because the sign used an old if. One Spot.fits.

    double for the tariff. 0.1 hour times a rate produces a cent you cannot explain. Integer minor units and explicit rounding.

    Releasing on every exit call. A retry frees a stall that a new ticket already owns, or throws and the gate retries forever. Transition once.

    A ParkingLot god class. Entry, price, sign text, and admin maintenance in one type. Split use cases. The lot's data is the inventory, not a class named after the building that does all the verbs.

    Enum ordinal as a business ranking. Reordering constants changes allocation. Use an explicit rank.

    Two processes and an in-memory lock presented as highly available. Availability of the process is not mutual exclusion across processes. Say which guarantee you have.

    Troubleshooting and Design Review

    Symptom: two tickets, one stall id. The critical section is incomplete or there are two inventories. Search for markOccupied callers. There should be one. Check instance count and whether the JDBC update predicate includes status = 'FREE'. Check that the unique index exists; if two tickets committed, the index is missing or the insert does not share the update's transaction.

    Symptom: stalls remain occupied with no open ticket. save threw and release did not run, or release ran on a different inventory instance than allocate. The compensation must use the same object the allocation mutated. In tests, force save to throw and assert FREE.

    Symptom: exit price does not match the board the driver photographed. Exit used the current policy. Inspect the ticket row for snapshotted rate and cap. If those columns are null, entry did not snapshot.

    Symptom: gates time out together at the top of the hour. A job holds the inventory lock while it scans history or calls the sign. Move the scan out. Copy ids under the lock; do the rest after.

    Symptom: vans are offered compact stalls on the sign and refused at the gate. Two fit implementations. Delete the sign's copy and have it call the same fits through an availability use case.

    Symptom: GarageFull while the board shows free stalls. Those stalls do not fit this vehicle, or they are free on the board's cached copy and occupied in the inventory. Include bay class in the availability response so "free" is not a single integer.

    Design review. Is select-and-mark atomic? Is price frozen at entry? Is exit idempotent? Does any lock cover I/O? Is fit in one method? Are vehicle types sealed? Is the multi-instance story written? Is there a test with two threads and many iterations? Does a failed ticket persist leave the stall free? Can an attendant take an occupied stall out of service? What happens to an OPEN ticket if the process restarts — is the inventory rebuilt from open tickets, or does a restart free every car?

    That last question is the production gap in a pure in-memory design. Restart recovery loads OPEN tickets and marks those stalls occupied before the gates open. If you skip that step, the first entries after a deploy double-book cars that are physically inside. The JDBC design is the occupied rows themselves; there is nothing to rebuild except the process. Prefer JDBC when restart behavior matters, which it does the first time you deploy during the day.

    Interview Questions and Answers

    Q1. How do you stop two gates from allocating the same stall?

    Make selection and the free-to-occupied transition one critical section. In one process that is a lock around both, or a compare-and-set on the stall that retries on failure. Across processes it is a conditional UPDATE … WHERE status = 'FREE' and a transaction that inserts the ticket in the same unit of work. A lock that only wraps the increment of a counter, after both threads have read the same id, does not work.

    Q2. Where do vehicle-to-stall rules live, and where do preferences live?

    Spot.fits answers legal occupancy: bay class, charger, and status. AllocationPolicy answers preference among legal stalls: floor, bay rank, charger first. Mixing preference into fits makes "smallest bay" look like a law and makes the sign's count disagree with a policy change. Mixing law into the policy lets a new policy admit a van to a compact stall.

    Q3. When is the price calculated, and why not at entry?

    The duration is known at exit, so the amount is calculated at exit. The inputs that finance must not change underneath a parked car — rate, cap, flat amount — are copied onto the ticket at entry. Entry can show an estimate. The estimate is not the receipt. Using the live policy object at exit reprices the garage when configuration reloads.

    Q4. How would you add motorcycles that may not use large stalls on event days?

    That is a policy change, not a change to Van's fit. An allocation policy can refuse large bays for motorcycles when an event flag is on, while fits still returns true so an attendant override remains possible, or fits takes a context if the restriction is a hard rule. Decide which. If it is hard, put it in fits with a tested event flag on the command. If it is a preference, put it in the policy. Say which stakeholders can override.

    Q5. What does your design do when ticket persistence fails after the stall is marked occupied?

    The in-memory path releases the stall before propagating the error. The SQL path rolls back the conditional update and the insert together, so there is no occupied stall without a ticket and no ticket without the stall. Leaving the stall occupied creates a ghost car the board cannot explain.

    Q6. How do you make exit safe under a booth retry?

    The transition OPEN to CLOSED happens once. A ticket that is already CLOSED returns the stored money and spot id and does not call release. The exit command's idempotency key maps to that receipt. Releasing unconditionally on every call can free a stall that a newer ticket owns.

    Q7. Why is a single ReentrantLock acceptable, and when is it not?

    It is acceptable when allocation is in-memory, fast, and local to one JVM, because the critical section only sorts a list and writes a status. It is not a cross-process mutex, and it is not acceptable if the section performs HTTP or waits on a device. A hotspot measured at the gate is the reason to move to shorter critical sections or to row-level conditional updates, not the reason to drop the lock.

    Q8. How do you test the race without sleeping for a fixed interval?

    Submit two tasks that wait on a CountDownLatch, release them together, and assert one success and one GarageFull for a one-stall inventory. Repeat hundreds of times so a missing lock fails the build reliably. Assert the stall's final status. A test with one thread does not address this requirement.

    Q9. How does this case study use Strategy, repository, and dependency injection without making the garage a pattern demo?

    Allocation and pricing vary for reasons operations and finance name, so they are strategies. Tickets and, in the SQL deployment, stalls are aggregates stored through repositories with one unit of work for exit. The composition root injects the policy and the inventory. Spot.fits stays a method because the variation is the vehicle hierarchy, which a sealed switch already covers. Patterns that do not have a change-driver do not get a class.

    Q10. A restart loses in-memory occupancy. What do you tell the operator?

    Before opening the gates, rebuild occupancy from OPEN tickets, or do not use in-memory occupancy as the source of truth. The database row is the source of truth when you have one. An in-memory design without a rebuild step double-books every car that was inside during the deploy. That sentence belongs in the design note, not in a postmortem.

    Q11. How do you model pay-before-exit without letting allocation reuse the stall?

    Add a PAID state and keep the stall occupied until the exit use case or an abandonment sweep releases it. The sweep goes through a use case that closes the ticket and frees the stall together. A SQL-only free of the stall leaves an OPEN or PAID ticket that a later exit will try to release again.

    Q12. What will you not design in the first version?

    Pathfinding inside the garage, a camera SDK, card PAN storage, a distributed lock service for a single-node campus, and a class per stall type that only stores a string. You will design fit, atomic allocate, snapshotted price, idempotent exit, and the multi-instance limitation. Those are the defects that show up in production and in the interview follow-ups.

    Hands-On Exercises

    Exercise 1: Allocate under contention

    Problem. Implement spots, fit, NearestEntrance, and InMemorySpotInventory.allocate for the vehicle rules in this lesson.

    Tasks.

    1. Write fit tests for motorcycle, car, van, charger, and out of service.
    2. Implement the critical section with tryLock and a timeout mapped to AllocationBusy.
    3. Add the two-thread test and run it in a loop. Fail if wins is not 1 for a single compact stall and two cars.
    4. Document the multi-process limitation in a paragraph.
    5. Show that the policy does not call markOccupied.

    Rubric. Requirements and fit rules 15%. Atomic allocate 25%. Concurrency test 20%. Failure modes (GarageFull, busy, out of service) 20%. Trade-offs including multi-instance 10%. Clarity 10%.

    Exercise 2: Exit price and idempotency

    Problem. Snapshot hourly rate and cap on the ticket at entry. Exit quotes from the snapshot, closes once, and releases once.

    Tasks.

    1. Change the default rate after entry and show the open ticket still exits at the old rate.
    2. Retry exit and show one release.
    3. Fail ticket save on entry and show the stall returns to FREE.
    4. Explain the JDBC alternative in which rollback replaces explicit release.
    5. Keep Money in integer minor units. Include the 59-minute and 60-minute cases.

    Rubric. Same weights. A solution that reads a mutable global rate at exit does not pass the design criterion.

    Lesson Summary

    The garage's low-level design is a set of invariants: legal fit in one method, select-and-mark atomic, price inputs frozen at entry, exit a single transition that frees the stall once, and persistence that cannot leave an occupied stall without a ticket. Strategies express preference and tariff shape. A sealed vehicle type expresses the fit hierarchy. A repository and a short transaction express the SQL deployment. A ReentrantLock expresses the single-process deployment and does not pretend to be more. Tests construct two threads and a one-stall inventory. The gate is an adapter. Lesson 21 applies the same discipline to a system where the hard state is not a stall flag but a car that must not reverse direction while people are still waiting ahead of it.

    Further Reading

    • Java ReentrantLock and ExecutorService documentation for the critical section and the test harness.
    • Spring Framework JdbcClient and transaction management, for the conditional update adapter and the unit of work on exit.
    • Behavioral Design Patterns for strategy and for state if you add PAID.
    • Lesson 18's optimistic update count, which is the same "zero rows means conflict" idea as WHERE status = 'FREE'.
    • PostgreSQL UPDATE and partial unique indexes, for one open ticket per stall.
    • Hexagonal Architecture and Ports & Adapters for the gate as a driving adapter and the inventory as a driven port.
    Ready to mark this lesson complete?Track your journey across the entire course.