AlgoInsight

Free lesson · Low Level Design Interviews

Step 3: Classes and Relationships

Hard13 min readlldinterview-prepoop

Step 3 - Classes & Relationships

by Olena Marchenko - Software Engineer II, Microsoft Azure Developer Tools.

The modeling phase committed six decisions: inheritance for vehicles, enum-based for spots, compatibility on the spot side, ticket lifecycle with four states and enum-validated transitions, precomputed capacity counters, and two Strategy interfaces for allocation and pricing. Step 3 converts that into specific class structures, method signatures, the orchestration boundary, and the concurrency design.

About 9-10 minutes from roughly minute 17 to minute 27. The class structure committed here becomes the scaffolding the implementation in Step 4 fills in.

Parking lot's class design has a load-bearing decision that I want to call out specifically: lock granularity. Coarse-grained lock at the ParkingLot level versus finer-grained locks at the Level. The decision affects throughput at concurrent gates, complexity of the implementation, and deadlock risk. The interviewer almost always probes it. Strong candidates surface it unprompted.

The Interviewer's Perspective

When the candidate transitions to class structure, my evaluation focuses on the orchestration and concurrency boundaries.

Did they commit to ParkingLot as the orchestrator? No separate service layer for parking lot - the Lot itself is the service. Some candidates add a ParkingLotService class that wraps a ParkingLot data class; this is unnecessary indirection. Strong candidates keep the Lot as both the data and the orchestrator.

Did they handle lock granularity deliberately? Three options: lock on ParkingLot (coarse, simple, serializes all gate operations), lock on each Level (finer, concurrent across levels, requires careful gate routing), lock per Spot (finest, highest concurrency, deadlock-prone). Strong candidates name all three and pick deliberately. The right answer depends on scale and gate count.

Did they design the Strategy interfaces with the right signatures? SpotAllocationStrategy.allocate(Lot, Vehicle) → Optional<Spot>. PricingStrategy.calculateFee(Ticket, Duration) → Money. The signatures matter - they're the API the rest of the system depends on.

Did they choose Optional vs null vs exceptions? Modern Java prefers Optional for absence. allocate can return Optional.empty when no compatible spot exists. The choice signals language fluency.

Did they protect invariants through encapsulation? Spots should not be directly settable from outside the Lot. Level's counters should update only through the Lot's allocate/release flow. Ticket status should transition only through validated methods. Strong candidates explicitly close off the invariant-breaking paths.

Did they think about the entry/exit gate concurrency? Two entry gates calling allocate at the same instant. Same spot can't be allocated twice. The lock protects this; the atomicity boundary matters.

Did they handle the PaymentProcessor abstraction cleanly? Out of scope to implement but in scope to define the interface. PaymentProcessor.process(Ticket, Money) → PaymentResult. Strong candidates define the interface; weak ones either skip it or implement it inline.

The class-and-relationships phase generates about 20% of the interviewer's signal on parking lot. Combined with requirements (25-30%) and modeling (35-40%), the first three steps generate roughly 80% of total signal. Implementation and the closing confirm.

Important: Parking lot's lock granularity decision is one of the highest-signal probes in the class structure phase. ParkingLot-level lock is the safe default for the committed scope. Articulating the alternatives (Level-level, Spot-level) and the trade-offs signals concurrency depth.

The Candidate's Perspective

The class structure phase for parking lot has specific shape:

Commit to ParkingLot as the orchestrator. Don't add a separate service layer.

Make the lock granularity decision deliberate. Surface the three options. Pick the one that fits the committed scope (usually ParkingLot-level for moderate gate counts) and articulate the shift triggers.

Define the Strategy interface signatures precisely. Method names, parameter types, return types.

Use modern return types. Optional for absence. Sealed result types for operations with multiple distinct outcomes (e.g., entry can succeed with a ticket, fail because lot is full, or fail because no compatible spot exists).

Encapsulate invariants. Spots are mutable only through the Lot. Ticket status transitions only through validated methods. Counter updates happen only inside lock-protected sections.

Articulate the entry/exit gate concurrency model. Multiple gates → one lock atomically allocates; gates serialize at the lock.

Define the PaymentProcessor interface. Don't implement it; just define the contract.

The candidate's job is to produce a clean class design with the orchestration boundary clear, the concurrency strategy articulated, and the Strategy interfaces signed cleanly.

Applied to Parking Lot

The class structure for parking lot, with the orchestration and concurrency design.

The ParkingLot Class as Orchestrator

public class ParkingLot {
    private final List<Level> levels;
    private final SpotAllocationStrategy allocationStrategy;
    private final PricingStrategy pricingStrategy;
    private final PaymentProcessor paymentProcessor;
    private final Map<String, ParkingTicket> activeTickets;
    private final ReentrantLock lock = new ReentrantLock();
    
    public ParkingLot(
        List<Level> levels,
        SpotAllocationStrategy allocationStrategy,
        PricingStrategy pricingStrategy,
        PaymentProcessor paymentProcessor
    ) { ... }
    
    public Optional<ParkingTicket> enter(Vehicle vehicle);
    public ExitResult exit(String ticketId, PaymentMethod paymentMethod);
    public int getAvailableCount(SpotType spotType);
    public int getAvailableCount(int levelNumber, SpotType spotType);
    
    // Private helpers
    private ParkingSpot allocateSpot(Vehicle vehicle);
    private void releaseSpot(ParkingSpot spot);
}

The ParkingLot is both data and orchestrator. The public API has four methods: enter, exit, two capacity queries. The private helpers handle the internal allocation and release mechanics.

The injected dependencies - three Strategy/Processor interfaces - make the Lot OCP-compliant. New allocation strategies, new pricing models, new payment processors are new classes that swap into the existing structure.

The Class Structure Visualized

classDiagram
    class ParkingLot {
        -levels: List~Level~
        -allocationStrategy: SpotAllocationStrategy
        -pricingStrategy: PricingStrategy
        -paymentProcessor: PaymentProcessor
        -activeTickets: Map~String, Ticket~
        -lock: ReentrantLock
        +enter(Vehicle) Optional~Ticket~
        +exit(String, PaymentMethod) ExitResult
        +getAvailableCount(SpotType) int
        -allocateSpot(Vehicle) ParkingSpot
        -releaseSpot(ParkingSpot)
    }
    
    class Level {
        -levelNumber: int
        -spots: List~ParkingSpot~
        -availableCounts: Map~SpotType, Integer~
        +findAvailable(Vehicle) Optional~ParkingSpot~
        +decrement(SpotType)
        +increment(SpotType)
    }
    
    class ParkingSpot {
        -id: String
        -spotType: SpotType
        -status: SpotStatus
        -vehicleParked: Vehicle
        +canFit(Vehicle) boolean
        +occupy(Vehicle)
        +release()
    }
    
    class Vehicle {
        <<abstract>>
        -licensePlate: String
        +vehicleType()* VehicleType
    }
    
    class SpotAllocationStrategy {
        <<interface>>
        +allocate(ParkingLot, Vehicle) Optional~ParkingSpot~
    }
    
    class PricingStrategy {
        <<interface>>
        +calculateFee(ParkingTicket, Duration) Money
    }
    
    class PaymentProcessor {
        <<interface>>
        +process(ParkingTicket, Money, PaymentMethod) PaymentResult
    }
    
    class ParkingTicket {
        -ticketId: String
        -vehicle: Vehicle
        -spot: ParkingSpot
        -entryTime: Instant
        -status: TicketStatus
        +transitionTo(TicketStatus)
    }
    
    class ExitResult {
        <<sealed>>
    }
    
    class Success {
        +ticket: ParkingTicket
        +fee: Money
    }
    
    class Failure {
        +reason: String
    }
    
    ExitResult <|-- Success
    ExitResult <|-- Failure
    
    ParkingLot "1" *-- "many" Level
    Level "1" *-- "many" ParkingSpot
    ParkingLot --> SpotAllocationStrategy
    ParkingLot --> PricingStrategy
    ParkingLot --> PaymentProcessor
    ParkingSpot --> Vehicle : holds when occupied
    ParkingTicket --> ParkingSpot
    ParkingTicket --> Vehicle

The class diagram makes the composition relationships visible. ParkingLot composes Levels, Strategies, and the PaymentProcessor. Tickets reference Vehicle and Spot. The sealed ExitResult hierarchy (Success / Failure) typifies the exit outcomes.

Lock Granularity - The Load-Bearing Decision

Three options for where the lock lives.

Option A: One lock on ParkingLot. Coarse-grained. Every allocation and release acquires the lot-level lock. Simple. Serializes all gate operations.

public Optional<ParkingTicket> enter(Vehicle vehicle) {
    lock.lock();
    try {
        // allocate, issue ticket, update counters
    } finally {
        lock.unlock();
    }
}

For our committed scope (two entry, two exit gates), this is sufficient. The lot-level lock serializes four concurrent operations - manageable contention. The implementation is simple.

Option B: One lock per Level. Finer-grained. Each Level has its own lock. Allocations on different levels run concurrently.

class Level {
    private final ReentrantLock lock = new ReentrantLock();
    public Optional<ParkingSpot> findAvailable(Vehicle vehicle) { ... }
}

Higher concurrent throughput at the cost of complexity. Need to handle the case where allocation strategy spans multiple levels - which lock to acquire first? Lock ordering matters; otherwise deadlock is possible.

Option C: One lock per Spot. Finest. Each spot has its own lock. Highest concurrency. Deadlock-prone if allocation locks multiple spots; complex if allocation searches across spots.

For our scope, Option A (ParkingLot-level) is correct. The articulation:

"I'm using a single ReentrantLock at the ParkingLot level. With two entry gates and two exit gates, the contention is manageable - four concurrent operations serializing at one lock. Simple, deadlock-free.

If we scaled to many more gates (think large airport with dozens of gates), I'd shift to per-Level locks for higher concurrency. Lock ordering would need careful design - if a strategy spans levels, lock them in a consistent order to avoid deadlock. Per-Spot locks are the maximum throughput option but introduce significant deadlock risk for allocation operations that need to compare multiple spots.

For our scope, lot-level is the right level of granularity."

That articulation surfaces all three options, names the shift trigger (more gates), and commits with reasoning. Senior signal.

Concurrency Boundary for enter

The enter operation has multiple sub-steps that must be atomic:

sequenceDiagram
    actor Gate
    participant Lot as ParkingLot
    participant Lock as ReentrantLock
    participant Strategy as AllocationStrategy
    participant Level as Level
    participant Spot as ParkingSpot
    
    Gate->>Lot: enter(vehicle)
    Lot->>Lock: lock()
    Lot->>Strategy: allocate(lot, vehicle)
    Strategy->>Level: findAvailable(vehicle)
    Level->>Spot: canFit(vehicle)?
    Spot-->>Level: yes/no
    Level-->>Strategy: Optional<Spot>
    Strategy-->>Lot: Optional<Spot>
    
    alt Spot found
        Lot->>Spot: occupy(vehicle)
        Lot->>Level: decrement(spotType)
        Lot->>Lot: create ParkingTicket
        Lot->>Lot: store in activeTickets
        Lot->>Lock: unlock()
        Lot-->>Gate: Optional.of(ticket)
    else No spot
        Lot->>Lock: unlock()
        Lot-->>Gate: Optional.empty()
    end

The sequence shows the atomicity: from the moment the lock is acquired to the moment the ticket is in activeTickets, no other thread can observe the cache. Two gates calling enter simultaneously will serialize - one fully completes before the other starts. No race on spot allocation.

Strategy Interface Signatures

Precise signatures matter.

public interface SpotAllocationStrategy {
    Optional<ParkingSpot> allocate(ParkingLot lot, Vehicle vehicle);
}

Why this signature: The strategy needs the Lot (to see Levels and Spots) and the Vehicle (to check compatibility). Returns Optional because allocation can fail (no compatible spot). Implementations:

public class FirstAvailableCompatibleStrategy implements SpotAllocationStrategy {
    @Override
    public Optional<ParkingSpot> allocate(ParkingLot lot, Vehicle vehicle) {
        for (Level level : lot.getLevels()) {
            Optional<ParkingSpot> spot = level.findAvailable(vehicle);
            if (spot.isPresent()) {
                return spot;
            }
        }
        return Optional.empty();
    }
}

public class NearestToEntranceStrategy implements SpotAllocationStrategy {
    @Override
    public Optional<ParkingSpot> allocate(ParkingLot lot, Vehicle vehicle) {
        // Walk levels in entrance-distance order; within each, sort spots by distance
        ...
    }
}
public interface PricingStrategy {
    Money calculateFee(ParkingTicket ticket, Duration stayDuration);
}

Implementations:

public class HourlyByTypePricing implements PricingStrategy {
    private final Map<SpotType, Money> hourlyRates;
    private final Money dailyCap;
    
    @Override
    public Money calculateFee(ParkingTicket ticket, Duration stayDuration) {
        long hours = (long) Math.ceil(stayDuration.toMinutes() / 60.0);
        Money baseRate = hourlyRates.get(ticket.getSpot().getSpotType());
        Money fee = baseRate.multiply(hours);
        return fee.compareTo(dailyCap) > 0 ? dailyCap : fee;
    }
}
public interface PaymentProcessor {
    PaymentResult process(ParkingTicket ticket, Money amount, PaymentMethod paymentMethod);
}

public sealed interface PaymentResult permits Success, Failure {
    record Success(String transactionId) implements PaymentResult {}
    record Failure(String reason) implements PaymentResult {}
}

Sealed Result Type for Exit

The exit operation has multiple distinct outcomes:

public sealed interface ExitResult permits Success, Failure {
    record Success(ParkingTicket ticket, Money feeCharged) implements ExitResult {}
    record Failure(String reason) implements ExitResult {}
}

Failure reasons include: "ticket not found", "ticket already closed", "payment failed", "lot closed for maintenance." The sealed interface forces callers to handle each case explicitly.

The articulation: "Exit returns a sealed ExitResult - Success carries the ticket and fee charged; Failure carries a reason. Forces callers to handle each case. Better than throwing exceptions for expected failure modes."

The Encapsulation Boundary

Public methods on ParkingLot:

Private/internal:

Level's methods are package-private - accessible from ParkingLot but not from callers.

ParkingSpot's occupy and release are package-private - only the Lot's allocation flow can call them.

The articulation: "Public API on ParkingLot is enter, exit, and two capacity queries. Everything else is encapsulated. Spots and Levels expose their state through methods, but mutations go only through the Lot's flow. This prevents callers from breaking invariants like the counter consistency."

Important: The lock granularity decision is parking lot's highest-signal probe in the class design phase. Surfacing all three options (lot-level, level-level, spot-level), picking the appropriate one for the scope, and articulating the shift triggers is the senior signal that lands hardest in this step.

The Conversation

What designing the class structure actually sounds like.

Strong Candidate

Interviewer: "Entities are committed. Move to classes."

Candidate: "ParkingLot is the orchestrator - both data and service. No separate service layer; the Lot is the service. Public methods: enter, exit, two capacity queries. Private helpers handle the allocation and release mechanics.

The Lot composes three injected dependencies: SpotAllocationStrategy, PricingStrategy, PaymentProcessor. All interfaces. The default implementations cover the committed scope; alternative implementations are how extensions happen."

Interviewer: "Concurrency?"

Candidate: "Three options for lock granularity. Option A: single ReentrantLock at the ParkingLot - coarse, simple, serializes all gate operations. Option B: per-Level locks - finer, concurrent across levels, requires lock ordering for cross-level operations to avoid deadlock. Option C: per-Spot locks - finest concurrency, significant deadlock risk for strategy operations that compare spots.

For our scope - two entry gates, two exit gates - Option A is the right choice. Four concurrent operations serializing at one lock is manageable contention. Simple, deadlock-free.

If we scaled to many more gates (large airport with dozens), I'd shift to per-Level locks with careful lock ordering. Per-Spot is the maximum-throughput option but has deadlock risk that isn't worth it without scale justification."

Interviewer: "Strategy signatures?"

Candidate: "SpotAllocationStrategy.allocate(lot, vehicle) returns Optional<ParkingSpot>. The strategy needs the Lot for Levels/Spots access, the Vehicle for compatibility check. Returns Optional because allocation can fail.

PricingStrategy.calculateFee(ticket, duration) returns Money. Strategy implementations handle hourly-by-type, flat-rate, time-of-day-variable, etc.

PaymentProcessor.process(ticket, amount, method) returns PaymentResult - a sealed type with Success and Failure variants. We don't implement the processor; we define the contract."

Interviewer: "Return types for enter and exit?"

Candidate: "enter returns Optional<ParkingTicket> - Optional.empty when no compatible spot is available. exit returns ExitResult - a sealed interface with Success (carrying ticket and fee charged) and Failure (carrying reason) variants. Forces callers to handle each case explicitly. Better than throwing exceptions for expected failure modes."

Interviewer: "Encapsulation?"

Candidate: "Public on the Lot: enter, exit, capacity queries. Private helpers for the allocation and release. Level's methods are package-private - accessible from Lot but not from external callers. ParkingSpot's occupy and release are package-private - only the Lot's allocation flow can call them. This prevents callers from breaking invariants like counter consistency."

About 8 minutes. Lock granularity articulated with all three options. Strategy signatures precise. Sealed result types committed. Encapsulation explicit. Senior signals throughout.

Weak Candidate

Interviewer: "Entities are committed. Move to classes."

Candidate: "I'll have a ParkingLot class. And a ParkingLotService class to handle the business logic."

Interviewer: "Why a separate service?"

Candidate: "To separate the data from the operations."

Interviewer: "What's the operations layer doing that ParkingLot can't?"

Candidate: "It... orchestrates the strategies?"

Interviewer: "ParkingLot could compose them directly. What's the value of a wrapper?"

Candidate: "Hmm. I guess I could put the orchestration on ParkingLot directly."

Interviewer: "Concurrency?"

Candidate: "I'll use synchronized methods on ParkingLot."

Interviewer: "What about per-Level locks?"

Candidate: "Oh, that's an option. Per-Level would allow concurrent allocation across levels."

Interviewer: "And what's the risk?"

Candidate: "Performance is better with per-Level?"

Interviewer: "What about deadlock?"

Candidate: "Right, if an operation needs two levels..."

Interviewer: "Strategy signatures?"

Candidate: "allocate takes a vehicle and returns a spot."

Interviewer: "From where? How does the strategy see the spots?"

Candidate: "Oh, it'd need the Lot too."

Interviewer: "Pricing?"

Candidate: "calculateFee takes hours and rate."

Interviewer: "What about variable rates by spot type?"

Candidate: "Hmm. It'd need the ticket so it can see the spot type."

Interviewer: "Right. Continue."

About 8 minutes. Concurrency and Strategy signatures arrived at through interviewer-prompted iteration. Service layer over-decomposition surfaced. Each design decision corrected rather than articulated upfront.

Strong vs Weak: Design Decisions

Decision Point Strong Candidate Weak Candidate
Orchestration boundary ParkingLot is data + service ParkingLot + ParkingLotService - corrected
Lock granularity 3 options articulated; committed with reasoning synchronized; prompted into per-Level
Strategy signatures Precise; arguments named with rationale Incomplete; arguments added when prompted
Pricing input Strategy gets full ticket "hours and rate" - corrected
Return types Optional + sealed ExitResult Plain V + null + exceptions
Encapsulation Level package-private; spot mutations only via Lot Public everywhere
Strategy injection Constructor-injected interfaces Implicit dependencies
Articulation pattern "I'm choosing X over Y because Z" Single choice, no rationale
Source of design Candidate-led Interviewer correction loop
Time used 8 min producing complete articulation 8 min producing correction history

Common Mistakes

The parking lot class-design failures I see most often:

Service layer over-decomposition. Adding ParkingLotService that wraps ParkingLot. The two collapse into one; the wrapper adds noise.

synchronized without granularity reasoning. Using synchronized without considering whether lot-level, level-level, or spot-level locking is right. Misses the depth probe.

Falling into the per-Spot lock trap. Some candidates over-correct to finest-grained locks "for performance." Without articulating deadlock risk, this signals concurrency naivete.

Strategy signatures incomplete. "allocate takes a vehicle" - missing the Lot reference. Forces correction.

Pricing signature too narrow. "calculateFee takes hours and rate" - missing the spot type variation. Forces correction.

Returning V with null. Modern Java idioms favor Optional. Missing this signals language fluency gap.

No sealed result types. Plain throw-on-failure for exit. Misses the typed-outcome opportunity.

Public spot mutations. Letting callers call spot.occupy directly. Breaks encapsulation; lets callers update without counter maintenance.

Forgetting the PaymentProcessor interface. Either implementing it inline (out of scope) or skipping it entirely.

Warning: The most damaging concurrency mistake in class design is falling into the per-Spot lock trap without articulating deadlock risk. Per-Spot locks are appealing because "more concurrency is better" - but if an allocation strategy compares multiple spots, the candidate needs lock-ordering discipline that most don't articulate.

What "Good" Looks Like

Concrete signals I score positively:

ParkingLot as data + service. Committed cleanly, no spurious service layer.

Lock granularity with three options. Lot-level, Level-level, Spot-level. Chosen with reasoning.

Strategy signatures complete. allocate(lot, vehicle), calculateFee(ticket, duration), process(ticket, amount, method).

Sealed ExitResult type. Success/Failure variants, forced handling.

Optional<ParkingTicket> for enter. Modern absence handling.

Encapsulation explicit. Public API named; private helpers named; mutation paths constrained.

Constructor injection for strategies. Not field setters; not service locator.

PaymentProcessor interface defined. Not implemented; just contracted.

Concise articulation. Each decision gets a sentence of rationale.

Clean transition out. "With structure committed, let me implement enter and exit with the lock and strategy calls."

A candidate hitting seven or more of these signals in 8-10 minutes has produced senior class design work.

Try It

Exercise: Drill the class-and-relationships phase for parking lot:

  1. Write the ParkingLot class signature from scratch. Four public methods, two private helpers, the composed dependencies. Time it.

  2. Drill the lock-granularity articulation. Three options, the trade-off matrix, the committed choice with shift trigger.

  3. Practice the Strategy signature precision. SpotAllocationStrategy.allocate(lot, vehicle). PricingStrategy.calculateFee(ticket, duration). PaymentProcessor.process(ticket, amount, method). Until each is reflexive.

  4. Drill the sealed ExitResult pattern. Success with ticket and fee. Failure with reason. The forced-handling property.

  5. Time the design phase. 8-10 minutes including all articulations. Faster suggests skipped rationale; slower means over-detailing.

The next step (Step 4) covers code implementation - fleshing out the method bodies with the lock pattern, the strategy calls, the ticket lifecycle transitions, and the counter updates. The class structure committed here becomes the scaffolding.

Back to Low Level Design Interviews