InterviewPrepKit

Home / Cheat Sheet / Object-Oriented Design

Cheat sheet

How to design a package-locker system

Read the full lesson →

A parcel-locker bank holds a package until pickup and gives the locker back when nobody collects; the design turns on expiry, not storage.

Three decisions

DecisionFailure it avoids
Pick smallest fitting locker, not first fitfirst-fit rejects packages the bank had room for
Expiry is a sweep over an injected clockreading the OS clock in the domain is untestable
Access codes scoped, hashed, expiringa 6-digit code that opens any door = 60 live doors in 1,000,000

Core objects

Reservation is the aggregate root: the object outside code talks to, owning the state machine, deadline, and access code. Promoting the delivery to an object separates three mismatched lifetimes: hardware (years), delivery (hours), shipment (before drop-off, after return).

ObjectOwnsNot its job
Lockersize, features, in-service, held_bydeadlines
Bayordered run of lockers (adjacency)allocation
LockerBankinventory, atomic claimchoosing a locker
AllocationPolicychoosing a lockerreservations
Reservationstate, deadline, code, transitionsopening doors
Clockcurrent time, nothing elseanything else
  • Size is an ordered enum (SMALL < MEDIUM < LARGE), so fits is one <=. Continuous dimensions would force 3D bin packing.
  • held_by = reservation id occupying a door, or None when free. free = in_service and held_by is None.
  • Relationships: Bay *-- Locker (composition, slot identity), Reservation o-- Locker (aggregation, wall outlives delivery), Reservation *-- AccessCode (composition).

Decision 1 — allocation is a Strategy

  • Strategy pattern: pull the varying rule into a swappable object with a fixed select(lockers, pkg) -> list[Locker]; return [] when nothing fits (an answer, not an error).
  • ScanOrder (first fit) hands a small package the large door, then rejects a large package the bank had room for. SmallestFit preserves scarce big doors and rejects nothing.
  • Return a list, so oversized packages spanning two doors need no signature change.
  • Cost: which locker you get is invisible in a stack trace; a ScanOrder-only bug won’t reproduce under the default.

Decision 2 — lifecycle on an injected clock

State machine (six states, closed):

FromEventTo
RESERVEDdepositAWAITING_PICKUP
RESERVEDsweep, deadline passedABANDONED (released)
AWAITING_PICKUPcorrect codePICKED_UP (terminal)
AWAITING_PICKUPextendAWAITING_PICKUP (deadline moves)
AWAITING_PICKUPsweep, deadline passedEXPIRED (stays held)
EXPIREDcourier retrievesRETURNED_TO_SENDER (released)
  • EXPIRED does not free the locker — the box is still inside; only courier retrieval releases it. Freeing at expiry double-books the door.
  • Time enters via one injected Clock (a Protocol); tests pass a FrozenClock. A 72-hour rule can’t be tested by waiting.
  • Expiry is a sweep (function over all reservations + now), not one timer per package. Nothing queued means nothing to cancel when a deadline moves, which makes “extend” free.
  • sweep is idempotent: second call finds EXPIRED, returns False — safe on a timer that double-fires.
  • extend adds to deadline, not now() — adding to now lets a prompt recipient shorten their own hold.
  • Guards raise on illegal transitions; use is not == on enum singletons.
  • Capacity: throughput = doors × 24 / dwell-hours. A 60-door bank serves ~20/day at full 72h holds, ~103/day at a 14h mean dwell. Hold length is the capacity knob, so HOLD_HOURS belongs on a per-bank policy.

Decision 3 — access codes: scope before length

  • Safety depends on scope (how many doors one guess opens), not digit count. Bank-wide keypad: 60 live codes in 1,000,000, guess worth 60/1,000,000. Per-door scope: 1/1,000,000, 60x weaker, same six digits. Per-door scoping also removes an enumeration oracle.
  • Three fields on AccessCode: stored as a salted hash (a bearer credential; a DB dump must not be a master key), uses_left (1 per package; a reusable code lets the next recipient reach in), expires_at (= reservation deadline).
  • verify checks expiry and uses_left before the digest, and decrements only on a correct code.
  • Security idioms: secrets.randbelow (crypto RNG, not random), zero-pad to 6 digits, hmac.compare_digest (constant time, defeats timing attacks).
  • Two counters: uses_left (on the code) vs rate limiting (on the keypad, source of the ~231-day figure). Conflating them lets an attacker burn a legit code by guessing.

Extensions and gotchas

  • Oversized: one new AdjacentPair policy; Reservation already holds a list. Free because slot existed and select returned a list. Sort by (bay_id, slot), reject the cross-bay pair.
  • Extend deadline: nothing changes — deadline is data, expiry recomputes each sweep. Prefer recomputed state over scheduled state.
  • Refrigeration: a feature flag in Locker.features / Package.needs, not a RefrigeratedLocker subclass. Capabilities compose; subclasses multiply. Feasibility lives in fits; preference (don’t waste a chilled door on a book) lives in the policy (ScarcityAware).
  • Claims must be atomic: select + write under one lock, or UPDATE lockers SET held_by=? WHERE ... AND held_by IS NULL with a row-count check. This is the check-then-act race.
  • Traps: LockerBank is not a Singleton (second bank ships); a global clock is what makes expiry untestable. Both are constructor arguments.
Want the full picture? The lesson has the derivations, worked examples, and diagrams this card compresses into bullets. Read the full lesson →
Report a bug