Free lesson · Low Level Design Interviews
Step 3: Classes and Relationships
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:
enter(Vehicle)→Optional<ParkingTicket>exit(String ticketId, PaymentMethod)→ExitResultgetAvailableCount(SpotType)→intgetAvailableCount(int levelNumber, SpotType)→int
Private/internal:
allocateSpot(Vehicle)- the strategy-driven allocation mechanicsreleaseSpot(ParkingSpot)- the release mechanics
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:
Write the ParkingLot class signature from scratch. Four public methods, two private helpers, the composed dependencies. Time it.
Drill the lock-granularity articulation. Three options, the trade-off matrix, the committed choice with shift trigger.
Practice the Strategy signature precision. SpotAllocationStrategy.allocate(lot, vehicle). PricingStrategy.calculateFee(ticket, duration). PaymentProcessor.process(ticket, amount, method). Until each is reflexive.
Drill the sealed ExitResult pattern. Success with ticket and fee. Failure with reason. The forced-handling property.
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.