What this chapter is
Design the locker bank in a supermarket lobby: a courier drops a package in, the recipient gets a code, and they collect it within three days.
A parcel locker bank is the metal cabinet in a supermarket lobby. A delivery driver opens a door, puts a parcel in, shuts it. The recipient gets a code by text, walks up, types the code, and takes the parcel.
This chapter designs the object model for that cabinet: the set of classes and the relationships between them. Three problems carry the weight:
- Allocation. Making the rule that picks a locker a swappable policy object rather than a loop buried inside a method.
- Lifecycle. Writing an expiry rule you can test in microseconds instead of three days, by making the current time an argument rather than something the code asks the operating system for.
- Access codes. Why a six-digit code is either safe or weak depending on a modelling decision unrelated to its length.
By the end you should be able to draw the class diagram from memory and defend every arrow in it, explain why an expired package must not free the locker it is sitting in, and show in running Python that the obvious allocation rule rejects a package the bank had room for.
The thing candidates get wrong
The interesting object is not the locker; it is the three-day hold.
Sizing and allocation have a clean answer. What separates candidates is whether the design can answer “what happens at hour 73?” without inventing a scheduled background job that nobody can test.
A locker holding a package nobody collected serves no one. Expiry is not an edge case here; it is the capacity model.
The three decisions
Three decisions carry the design. The middle column is the failure each decision exists to avoid.
| The decision | The consequence | Section |
|---|---|---|
| Smallest locker that fits, not first that fits | a wrong policy rejects packages the bank had room for | Decision 1 |
| Expiry is a sweep over an injected clock | asking the operating system for the time from inside the domain makes the rule untestable | Decision 2 |
| Access codes are scoped, hashed, and expiring | a 6-digit code that opens any door is 60 live doors in 1,000,000 | Decision 3 |
The ask
Strip the problem to one sentence before drawing anything. The sentence tells you which object is in charge:
Hold a package in a locker until its recipient collects it, and give the locker back when they do not.
The second half is the harder half. Handing a locker out is easy. Taking it back on a schedule, without a human remembering to, is what the classes have to be arranged for.
What goes in, and what comes out
Fix the shape of the system before drawing classes. Name the calls a caller makes and what each one returns, so you know what the tests can assert on later.
Five operations make up the entire public surface. The block below is a sketch of those signatures, not runnable code; every name in it is defined later in the chapter. Read it for the return types: two of them are decisions, not details.
IN bank.claim("r1", Package("p1", Size.SMALL))
OUT a LIST of lockers, e.g. [locker S1], each now marked held_by="r1".
An empty list [] means the bank had nothing that fits. That is an
ANSWER, not an error: the courier is told to try another bank.
IN reservation.deposit(clock)
OUT nothing is returned. The observable change is that the reservation's
state is now AWAITING_PICKUP and its deadline is set to
clock.now() + 72 hours
IN AccessCode.mint(expires_at)
OUT a PAIR: the AccessCode object to store, and the plaintext digits to
text to the recipient. The plaintext is never stored anywhere; only
a hash of it is. (`plaintext` means the code as the human reads it,
"418902", as opposed to the scrambled form kept in the database.)
IN code.verify("418902", clock)
OUT True or False. On True the code's remaining-uses counter drops by one,
so a second call with the same correct code returns False
IN reservation.sweep(clock)
OUT True if THIS call moved the reservation to EXPIRED, False if there was
nothing to do. Safe to call a thousand times; only the first one moves
held_by appears twice above, so define it now: it is a field on a Locker holding the id of the reservation currently occupying that door, or None when the door is free. It is the only mark of occupancy in the design.
The two signatures that are decisions
claim returns a list of lockers rather than one locker. That lets an oversized package occupy two doors later without editing anything (extension 1).
Every method that cares about time takes a clock argument instead of reading the system time itself. That is what makes a 72-hour rule testable (Decision 2).
Both are argued for in full later.
The output is mostly not a return value
The output of this design is a set of observable state changes, not returned objects. Three things are observable:
- which locker’s
held_byfield now names which reservation, - which state the reservation is in,
- what its deadline is.
Stating that in an interview is how you make the code testable before writing any of it. Every assertion later in this chapter reads one of those three.
Clarifying questions that change the design
Ask only questions whose answers move a class boundary, and say why as you ask. These four each add or delete a class rather than adjusting one; either answer commits you to a different drawing.
| Question | Answered yes | Answered no |
|---|---|---|
| Can one package span more than one locker? | the allocator returns a list of lockers, and adjacency — which doors are physically next to each other — becomes a modelled property of the bay | a single locker id is enough, and Bay is decoration |
| Does the recipient get the code before or at drop-off? | the code is a property of the reservation, minted when the booking is made | the code is a property of the occupancy, minted when the courier shuts the door |
| Are lockers reserved ahead of the courier’s arrival? | the bank has two different kinds of “unavailable”, and a reservation can expire without a package ever arriving | occupancy is a single boolean flag |
| Who physically removes an expired package? | a Courier actor with its own authentication path and its own state transition | expiry is a status flag and a report someone reads |
The third question is the important one. “Reserved but empty” and “occupied” are different states with different timeouts, and a design that collapses them into one boolean cannot express the rule “the courier never showed up, release the locker after 4 hours.” A boolean holds two answers and this system needs three.
Actors and use cases
An actor is anyone or anything that starts an interaction with the system. Listing them matters here because one of them is not a person, and that fact reorganises the design.
The third column names the state transition each use case causes. -> reads as “moves the reservation to”.
| Actor | Use case | The state it moves |
|---|---|---|
| Courier | reserve a locker, deposit a package | -> RESERVED, -> AWAITING_PICKUP |
| Recipient | enter a code, open a door, take the package | -> PICKED_UP |
| Recipient | extend the deadline | deadline moves, state does not |
| Clock (the system itself) | expire an overdue package | -> EXPIRED |
| Courier | retrieve an expired package for return | -> RETURNED_TO_SENDER |
The fourth row has no human in it. Nobody presses a button to expire a package; the passage of time causes the transition. The clock initiates a state transition, so the clock is a dependency, so it is injected: handed to the objects that need it as a constructor or method argument, rather than fetched from the operating system deep inside a method. Decision 2 covers why.
Core objects, and why those
The actor list said what the system does; the next question is which objects hold it. Every object here has to clear the same bar: why is it not simply a field on something else?
Why the obvious model fails
The obvious model has a Locker holding a Package and a status string. Two classes, done.
It fails on the first requirement change, because it fuses three lifetimes that do not match each other:
- the hardware lasts years,
- one delivery lasts hours,
- the shipment exists before drop-off and after return-to-sender.
Fusing them breaks the audit question “which packages passed through locker 14 last month?” The object that knew the answer was overwritten by the next delivery, so answering now needs a separate log.
Reservation is the aggregate root
The design promotes the delivery itself to an object.
An aggregate root is the object that outside code is allowed to talk to, and which enforces the rules for the cluster of objects behind it. Here Reservation owns the state machine, the deadline, and the access code. It refers to a package and holds lockers that it does not own.
Six objects fall out. The right-hand column answers an interviewer asking why a responsibility is not on the object next door.
| Object | Responsibility | Explicitly not its job |
|---|---|---|
Locker | size, features, in-service flag, current holder id | knowing about deadlines |
Bay | an ordered run of lockers, which is what makes “adjacent” meaningful | allocation |
LockerBank | the inventory, and claiming lockers in one indivisible step | choosing which locker |
AllocationPolicy | choosing which locker | knowing about reservations |
Reservation | state, deadline, code, and the transitions between them | opening doors |
Clock | the current time, and nothing else | anything else |
Why Size is an enum and not three measurements
One modelling choice in that table deserves its own defence.
Size is an ordered enumeration: a fixed list of named values with a defined order, so that SMALL < MEDIUM < LARGE is a valid comparison. It is not a (width, height, depth) triple of real measurements.
Real banks quantize door sizes (they offer three or four fixed sizes instead of a continuous range) because the hardware does. Against a quantized value, the fit test is a single <=.
Model the continuous dimensions instead and you have signed up for three-dimensional bin packing: placing boxes of arbitrary shapes into containers without wasting space, a problem with no fast exact solution. You would be solving it to answer a question the hardware already answered.
State that trade-off explicitly, because an interviewer who wanted the packing problem will say so.
Class diagram
Read the boxes first; the lines carry claims of their own, and each arrow is restated in plain English below.
classDiagram
class LockerBank {
+claim(str res_id, Package) List~Locker~
+release(List~Locker~)
}
class Bay {
+str bay_id
+List~Locker~ lockers
}
class Locker {
+str locker_id
+str bay_id
+int slot
+Size size
+frozenset features
+bool in_service
+str held_by
+bool free
+fits(Package) bool
}
class Reservation {
+str res_id
+State state
+datetime deadline
+int extensions_used
+deposit(Clock)
+extend(Clock) datetime
+sweep(Clock) bool
+pick_up(str attempt, Clock) bool
}
class Package {
+str tracking_id
+Size size
+frozenset needs
}
class AccessCode {
+str digest
+str salt
+int uses_left
+datetime expires_at
+mint(datetime, int) tuple
+verify(str, Clock) bool
}
class AllocationPolicy {
<<abstract>>
+select(List~Locker~, Package) List~Locker~
}
class Clock {
<<interface>>
+now() datetime
}
LockerBank "1" *-- "1..*" Bay : composition
Bay "1" *-- "1..*" Locker : composition
LockerBank "1" --> "1" AllocationPolicy : delegates to
LockerBank "1" o-- "0..*" Reservation : aggregation
Reservation "1" o-- "1..*" Locker : holds, does not own
Reservation "1" --> "1" Package : refers to
Reservation "1" *-- "1" AccessCode : composition
Reservation ..> Clock : injected per call
AccessCode ..> Clock : injected per call
AllocationPolicy <|.. ScanOrder
AllocationPolicy <|.. SmallestFit
AllocationPolicy <|.. AdjacentPair
AllocationPolicy <|.. ScarcityAware
Reading the notation
This is a UML class diagram. UML is the Unified Modeling Language — the standard set of shapes for drawing software structure.
- Each box is a class. The lines inside it are its fields and its methods.
- A leading
+marks a member as public, meaning visible to code outside the class. List~Locker~is mermaid’s way of writingList<Locker>, a list whose elements areLockerobjects. Mermaid uses tildes because angle brackets would collide with HTML.- A stereotype is an extra label in double angle brackets.
<<abstract>>marks a class that is never created on its own and exists only to be inherited from.<<interface>>marks a pure contract: a list of method signatures with no implementation behind them. - The quoted numbers at the ends of a line are multiplicities, saying how many objects sit at that end.
1is exactly one,1..*is one or more,0..*is any number including none.
Two entries need a note. Locker.free is written as a field but is computed, not stored — it is in_service and held_by is None. AccessCode.mint is called on the class rather than on an instance, and returns a pair.
Reading the four line styles
The arrowheads carry claims that the boxes do not. Four styles appear, each meaning something different:
*--composition, drawn with a filled diamond at the owner’s end. The owner controls the part’s lifetime, so destroying the owner destroys the part.o--aggregation, drawn with a hollow diamond. A “has a” relationship that does not own a lifetime, so the part can outlive the whole or be shared.-->association, a plain arrow. This object holds a reference to that one and can call it...>dependency, dashed. This object uses that one but does not keep it as part of its own structure.
One special case: <|.. realization, a dashed line with a hollow triangle. The class at the tail implements the interface at the head.
Reading the arrows back as sentences
Restate each line in words:
- A
LockerBankcomposes one or moreBays, and eachBaycomposes one or moreLockers. Scrapping the bank scraps the cabinets and the doors with it. - The bank holds a reference to exactly one
AllocationPolicyand delegates the choice of locker to it. - The bank aggregates any number of
Reservations, including none, because a reservation is a record that can be archived independently of the hardware. - A
Reservationholds one or moreLockers without owning them, refers to exactly onePackage, and composes exactly oneAccessCode. The code is meaningless once the reservation is gone, so it dies with it. ReservationandAccessCodeboth depend onClock, handed in as a method argument every time.ScanOrder,SmallestFit,AdjacentPairandScarcityAwareeach realize theAllocationPolicycontract. Any of them can be dropped into the bank without the bank noticing the difference.
Two of those arrows are the ones an interviewer will push on.
Reservation o-- Locker is aggregation because destroying the reservation must not destroy the locker. The locker is hardware bolted to a wall and it survives everything.
Bay *-- Locker is composition because a locker has no meaning outside its bay. Its identity is the bay plus the slot index, and that pair is what makes adjacency computable.
Draw both as *-- and you have said the hardware is deleted when a delivery completes.
Where the diagram and the Python differ
A class diagram is a model. The Python in this chapter is the shortest thing that runs and proves the arguments. They are not the same artifact, and three places diverge:
Bay has no Python class. The code flattens it into two fields on Locker, bay_id and slot, and LockerBank takes a flat list[Locker]. That is enough to compute adjacency (same bay_id, slot differing by one), which is the only thing Bay was there for. Bay is a class in the model and two fields in the sketch.
Two ownership arrows are not wired up in the code. Reservation *-- AccessCode and LockerBank o-- Reservation are real design claims, but the sketch never stores the pointers: AccessCode is built and exercised standalone in Decision 3, and nothing here keeps a registry of reservations. Wiring them up would add two fields and no new argument, so they are safe to leave out of a chapter this size.
Reservation.pick_up has no Python here. It is the one transition in the state machine with no implementation, because it needs AccessCode, which this chapter introduces two sections later. It is six lines, shown here as a sketch rather than a block to run:
def pick_up(res, code, attempt, clock):
if res.state is not State.AWAITING_PICKUP:
raise ValueError(f"cannot pick up from {res.state}")
if not code.verify(attempt, clock):
return False
res.state = State.PICKED_UP # terminal: no transition leads out
return True # the caller then releases the lockers
Everything else in the diagram — every other field and every other method — is implemented and exercised below.
What this class structure assumes
A class diagram is a bet about what will change. Every interface you draw says “I expect this to vary”; every hard-coded field says “I expect this to hold forever”. Naming those bets is the transferable skill. The general form of the argument is What a class structure assumes; what follows is this design’s version.
Assumed to vary, and therefore given an interface, a parameter, or a data field:
| What varies | How the design absorbs it | What it would cost to have got this wrong |
|---|---|---|
| Which locker a package goes into | The AllocationPolicy interface, chosen per bank | The rule is a loop inside claim, and a pharmacy’s bank needs a fork of the bank class |
| How many lockers one delivery uses | select returns a list; Reservation.lockers is a list | Oversized packages edit five files, as extension 1 shows |
| What time it is | The injected Clock | The expiry rule is untestable, so the commercially important rule ships unverified |
| What a locker can do beyond holding a box | features and needs are sets of strings, so a new capability is data | A subclass per capability, multiplying into RefrigeratedOversizedLocker |
| How long a hold lasts and how far it can be extended | Constants now, a HoldPolicy object when the second bank appears | A hospital and an apartment block need the same 72 hours forever |
Assumed fixed, and therefore baked into the structure rather than into a parameter:
- Door sizes are a short ordered list. That makes
fitsa comparison instead of a solver. - A bay is a one-dimensional run of slots. So “adjacent” means the slot index differs by one.
- A reservation covers exactly one package for exactly one recipient. That is why
Reservation.packageis singular. - The set of states is closed at six, and the legal moves between them are the whole machine. A seventh situation is a code change, not a configuration change.
- The bank is one process with one lock in memory. This is the assumption the concurrency paragraph breaks at the end of extension 1.
What a different assumption would have produced. This shows whether you chose the design or copied it.
- If door sizes were continuous rather than quantized,
Sizedisappears,fitsbecomes a geometric test, and allocation becomes three-dimensional bin packing, solved with an approximate rule plus a way to score how much space each answer wasted, because the exact answer is unaffordable. That is a different, much longer interview. - If a bay were a two-dimensional grid rather than a row, adjacency stops being
slot + 1.Lockercarries(row, column), andAdjacentPairbecomes a neighbour query against the grid. Nothing else in the design moves, which is the tell thatslotwas the right place for the assumption. - If the recipient booked the locker instead of the courier, the reservation would exist before the package does,
packagewould become optional, and theRESERVEDstate would need the timeout that this design gives it under the nameABANDONED. The state machine already anticipates that, which is why it has six states and not five. - If the credential were a phone-generated barcode rather than typed digits, the whole of Decision 3 changes shape: verification becomes a signature check, guessing stops being the threat, and the interesting question moves to what happens when the recipient’s phone is dead.
- If there were many banks behind one service, the in-process lock is wrong and the claim becomes a conditional database update, the shape given at the end of extension 1.
Decision 1 — allocation is a Strategy, and the default one is wrong
Which door should a package get? The obvious answer rejects packages the bank had room for. The durable fix is not a better loop; it is pulling the rule out into a swappable object, so a second bank with different priorities is a constructor argument rather than a fork.
The naive rule loses a package
The naive allocator scans the bank in physical order and takes the first door the package fits through.
Physical order is roughly by column, and columns mix sizes, so the first fit for a small package is frequently a large door.
Two arrivals show the loss. The block below is a hand-worked trace, not runnable code; it is the same scenario the Python further down asserts on.
bank 1 large, 1 medium, 1 small
arrivals small, then large
scan order gives small -> LARGE, large -> nothing free rejected = 1
smallest fit gives small -> SMALL, large -> LARGE rejected = 0
The small package arrives first. The scanner hands it the large door, because that door came first in the sweep. The large package arrives a minute later and is turned away from a bank that had a large door free when the day started.
Picking the smallest fitting door instead preserves the scarce big doors for the packages that need them, and rejects nothing.
Making the rule swappable
The fix is the Strategy pattern: pull the varying rule out into its own object with a fixed method signature, and let the caller choose which one to install.
The cost is one indirection: reading bank.claim(...) no longer tells you which locker you get, because that depends on which policy object was passed to the constructor.
The next block builds the vocabulary the chapter uses: Size, Package, Locker, the AllocationPolicy contract, and the two competing policies. The two select methods at the bottom are the point of comparison: one takes the first fit, one takes the smallest fit.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import IntEnum
from typing import Protocol
class Size(IntEnum):
SMALL = 1
MEDIUM = 2
LARGE = 3
@dataclass(frozen=True)
class Package:
tracking_id: str
size: Size
needs: frozenset[str] = frozenset()
@dataclass
class Locker:
locker_id: str
bay_id: str
slot: int # position within the bay: adjacency is slot +- 1
size: Size
features: frozenset[str] = frozenset()
in_service: bool = True
held_by: str | None = None
@property
def free(self) -> bool:
return self.in_service and self.held_by is None
def fits(self, pkg: Package) -> bool:
return self.free and pkg.size <= self.size and pkg.needs <= self.features
class AllocationPolicy(ABC):
"""Returns the lockers to claim, or [] if the package cannot be housed."""
@abstractmethod
def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]: ...
class ScanOrder(AllocationPolicy):
"""First door it fits through, in physical order. The wrong default."""
def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]:
return next(([lk] for lk in lockers if lk.fits(pkg)), [])
class SmallestFit(AllocationPolicy):
def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]:
usable = [lk for lk in lockers if lk.fits(pkg)]
if not usable:
return []
# Tie-break on slot so the choice is deterministic and therefore testable.
return [min(usable, key=lambda lk: (lk.size, lk.slot))]
The Python idioms in that block
Six idioms there carry a design decision rather than saving keystrokes. They recur for the rest of the chapter.
from __future__ import annotationstells Python to keep every type annotation as text instead of evaluating it when the class is defined. Without it, thestr | Noneonheld_byis an error on Python versions before 3.10, andlist[Locker]insideLockeritself would refer to a class that does not exist yet. It costs nothing at run time.IntEnumis an enumeration whose members are also integers, soSize.SMALL < Size.LARGEis true andpkg.size <= self.sizeis the entire fit test for volume. A plainEnumwould not compare, and that is precisely the property being bought here.@dataclassgenerates the constructor, the equality test and the string form from the field list, so the class body reads as a declaration of what a locker is.frozen=TrueonPackageadditionally makes instances immutable — once created, no field can be reassigned — which is right for a package, because a package’s tracking id and size are facts, not settings.frozensetis an immutable set, andpkg.needs <= lk.featuresis the subset test: every capability the package requires is a capability this locker has. That one operator is the entire feature-matching rule, and it stays one operator no matter how many features exist.@propertymakesfreeread like a field (locker.free, no parentheses) while staying a computed answer, so “free” can never drift out of sync within_serviceandheld_by.next((... for ...), [])tests the lockers one at a time, stops at the first that fits and returns it, and hands back the default[]if none of them did. It is a first-match loop with an explicit “nothing matched” answer, written in one line.
ABC and @abstractmethod come from Python’s abstract base class machinery. AllocationPolicy cannot be instantiated, and any subclass that forgets to write select fails loudly when someone tries to create it, rather than quietly at the first call. The ... in the body is Python’s Ellipsis literal, used here as a “no body” placeholder.
Proving the loss
rejects builds a three-locker bank, runs a list of arrivals through a policy, and counts how many were turned away. The two assertions at the bottom are the argument: same arrivals, different policy, different number of lost packages.
def rejects(policy: AllocationPolicy, arrivals: list[Package]) -> int:
lockers = [Locker("L1", "A", 0, Size.LARGE), Locker("M1", "A", 1, Size.MEDIUM),
Locker("S1", "A", 2, Size.SMALL)]
missed = 0
for pkg in arrivals:
chosen = policy.select(lockers, pkg)
missed += not chosen
for lk in chosen:
lk.held_by = pkg.tracking_id
return missed
arrivals = [Package("p1", Size.SMALL), Package("p2", Size.LARGE)]
assert rejects(ScanOrder(), arrivals) == 1
assert rejects(SmallestFit(), arrivals) == 0
Two lines need decoding.
missed += not chosen leans on two Python facts. An empty list is falsy, so not chosen is True exactly when nothing was allocated. And True is 1 when added to an integer. Together they count rejections.
The assert statements are the proof. An assert raises an error if its condition is false and does nothing otherwise, so a block of them that runs to completion is a passing test. Every claim in this chapter is checked this way. Here they say: the same two arrivals lose a package under ScanOrder and lose nothing under SmallestFit.
What the Strategy buys and what it costs
Cheap: the operator of a bank next to a pharmacy wants refrigerated doors held back for prescriptions. The operator of a bank in an office lobby wants as many packages through the same doors per day as possible. Same code, different constructor argument.
Costly: two policies is one policy plus a decision that is invisible in a stack trace. A bug that only appears under ScanOrder will not reproduce in a test that constructs the bank with the default.
Decision 2 — lifecycle runs on a clock you can inject
Allocation put the package in a door; now the three days begin. The reservation’s lifecycle is an explicit table of legal moves, and time enters it as an argument, which lets a three-day rule be tested without waiting three days.
The state machine
A state machine is a design where an object is in exactly one named situation at a time, and each situation allows only certain events, each moving the object to a named next situation. A guard is the extra condition that has to hold for a move to be allowed.
Written out, the reservation’s machine is a seven-row table. Read each row as a sentence: from this state, this event happens, and if this guard holds, the reservation moves to that state.
| From | Event | Guard | To |
|---|---|---|---|
RESERVED | courier deposits | code minted, door closed | AWAITING_PICKUP |
RESERVED | sweep | now >= reserve_deadline | ABANDONED (lockers released) |
AWAITING_PICKUP | correct code | now < deadline and uses_left > 0 | PICKED_UP |
AWAITING_PICKUP | recipient extends | extensions_used < cap | AWAITING_PICKUP (deadline moves) |
AWAITING_PICKUP | sweep | now >= deadline | EXPIRED |
EXPIRED | courier retrieves | courier credential | RETURNED_TO_SENDER (lockers released) |
PICKED_UP | any | — | terminal |
The same machine as a diagram:
stateDiagram-v2
[*] --> RESERVED
RESERVED --> AWAITING_PICKUP : deposit
RESERVED --> ABANDONED : sweep, deadline passed (lockers released)
AWAITING_PICKUP --> AWAITING_PICKUP : extend (deadline moves)
AWAITING_PICKUP --> PICKED_UP : correct code
AWAITING_PICKUP --> EXPIRED : sweep, deadline passed (lockers stay held)
EXPIRED --> RETURNED_TO_SENDER : courier retrieves (lockers released)
PICKED_UP --> [*]
Two things fall out of writing it as a table rather than as prose, and both are answers you will be asked for.
EXPIRED does not release the locker. Look at row five: the To column has no “(lockers released)” note, and rows two and six do. The package is still physically inside the door. A design that frees the locker at expiry has double-booked it: the software believes the door is empty while a box sits in it. Only the courier’s retrieval frees it.
There is no transition out of PICKED_UP. That makes “the code was reused an hour later” a bug in the implementation rather than an open policy question.
Nothing in the domain asks the OS what time it is
The rule that matters: nothing in the domain asks the operating system what time it is.
The domain is the set of objects that carry the business rules, as opposed to the code that wires them up. Time enters the domain through one injected object, which means every method that needs the time receives it as an argument.
The general argument for this, and its cost (a clock argument threaded through every object that can expire), is made over a 120-second seat hold in ch 05, decision 2.
What differs here is the horizon. A two-minute seat hold can be tested by waiting, badly. A 72-hour hold cannot be tested at all without an injectable clock, so untestable time stops being an inconvenience and becomes a design defect: the expiry rule, the commercially important rule in this system, would ship unverified.
The clock is a Protocol
Clock is declared as a Protocol, Python’s name for a structural interface: any object that has a now() method returning a datetime counts as a Clock, with no inheritance and no registration required.
Production passes a clock that reads the system time. Tests pass the FrozenClock below, which returns whatever instant the test last set.
class Clock(Protocol):
def now(self) -> datetime: ...
@dataclass
class FrozenClock:
t: datetime
def now(self) -> datetime:
return self.t
def advance(self, hours: float) -> None:
self.t += timedelta(hours=hours)
FrozenClock holds a fixed instant and moves only when the test moves it. timedelta is Python’s duration type, so self.t += timedelta(hours=hours) means “jump forward this many hours”, with the calendar arithmetic handled for you.
Expiry is a sweep, not a timer
Expiry is a sweep: a function of the reservations and the current time, run periodically over all of them. It is not one scheduled alarm per package.
Nothing is queued in advance, so nothing has to be cancelled when a deadline moves. That single property is what makes the “extend the deadline” feature free later.
The next block has the six states, the three policy constants, and the Reservation class with its three transitions. Note the comment on the line that sets EXPIRED: the lockers are deliberately left held.
from __future__ import annotations
from enum import Enum
class State(Enum):
RESERVED = "reserved"
AWAITING_PICKUP = "awaiting_pickup"
PICKED_UP = "picked_up"
EXPIRED = "expired"
RETURNED_TO_SENDER = "returned"
ABANDONED = "abandoned"
HOLD_HOURS = 72
MAX_EXTENSIONS = 1
EXTENSION_HOURS = 48
@dataclass
class Reservation:
res_id: str
package: Package
lockers: list[Locker]
state: State = State.RESERVED
deadline: datetime | None = None
extensions_used: int = 0
def deposit(self, clock: Clock) -> None:
if self.state is not State.RESERVED:
raise ValueError(f"cannot deposit from {self.state}")
self.state = State.AWAITING_PICKUP
self.deadline = clock.now() + timedelta(hours=HOLD_HOURS)
def extend(self, clock: Clock) -> datetime:
if self.state is not State.AWAITING_PICKUP:
raise ValueError(f"cannot extend from {self.state}")
if self.extensions_used >= MAX_EXTENSIONS:
raise ValueError("extension cap reached")
self.extensions_used += 1
assert self.deadline is not None
# Extend from the deadline, not from now: extending from now would let a
# recipient shorten their own hold by extending early.
self.deadline += timedelta(hours=EXTENSION_HOURS)
return self.deadline
def sweep(self, clock: Clock) -> bool:
"""True if this call moved the reservation. Idempotent by construction."""
if self.state is State.AWAITING_PICKUP and self.deadline is not None \
and clock.now() >= self.deadline:
self.state = State.EXPIRED # lockers stay held: the box is in there
return True
return False
Three details in that class do more work than they appear to.
Every method opens with a guard that raises. It does not silently do nothing. An illegal transition becomes a loud failure at the moment of the mistake, instead of a wrong state discovered an hour later.
state is not State.RESERVED uses is, not ==. Enum members are singletons (there is exactly one State.RESERVED object in the process), and identity comparison cannot be fooled by a same-looking value from somewhere else.
sweep is idempotent. Idempotent means calling it repeatedly has the same effect as calling it once. The second call finds the state is already EXPIRED, fails the AWAITING_PICKUP check, and returns False. That makes it safe to run the sweep on a timer that occasionally fires twice.
One gap: this sweep implements row five of the state table (AWAITING_PICKUP -> EXPIRED) and not row two (RESERVED -> ABANDONED). Row two needs a second deadline field, reserve_deadline, set when the reservation is created, and a second branch with the same shape. It is the same mechanism twice, so the chapter shows it once.
Why extend adds to the deadline and not to now()
The comment inside extend names a real exploit.
Extending from now() rather than from the existing deadline would mean a recipient who extends one hour after drop-off gets 48 hours from that moment: 49 hours total instead of the 120 they were promised. They would have shortened their own hold by being prompt.
Adding to self.deadline instead makes early extension harmless.
A whole package lifetime in microseconds
This trace runs a package through its entire three-day life. Each advance is a jump in simulated time; every assertion states what must be true at that instant. Nothing sleeps.
clock = FrozenClock(datetime(2026, 3, 1, 9, 0))
lk = Locker("S1", "A", 2, Size.SMALL)
res = Reservation("r1", Package("p1", Size.SMALL), [lk])
lk.held_by = "r1"
res.deposit(clock)
assert res.deadline == datetime(2026, 3, 4, 9, 0) # 9:00 + 72 h
clock.advance(71)
assert res.sweep(clock) is False and res.state is State.AWAITING_PICKUP
assert res.extend(clock) == datetime(2026, 3, 6, 9, 0) # + 48 h
clock.advance(2) # now hour 73, past the old line
assert res.sweep(clock) is False # extension held
try:
res.extend(clock)
except ValueError as e:
assert "cap" in str(e)
clock.advance(48) # hour 121, past the new line
assert res.sweep(clock) is True
assert res.state is State.EXPIRED
assert lk.held_by == "r1" # still occupied: correct
assert res.sweep(clock) is False # sweep is idempotent
Laid out hour by hour:
| Simulated time | What happens | Deadline after |
|---|---|---|
| 1 Mar 09:00 (hour 0) | courier deposits | 4 Mar 09:00 (+72 h) |
| hour 71 | sweep finds nothing | 4 Mar 09:00 |
| hour 71 | recipient extends once | 6 Mar 09:00 (+48 h) |
| hour 73 | sweep finds nothing — past the old line | 6 Mar 09:00 |
| hour 73 | second extend refused, cap is 1 | 6 Mar 09:00 |
| hour 121 | sweep fires, state becomes EXPIRED | 6 Mar 09:00 |
| hour 121 | locker still held by r1 | — |
Two rows matter most.
At hour 73, past the original deadline, the sweep still finds nothing to do. That is the behaviour a paid extension has to produce, and it comes for free because the sweep recomputes from the deadline field instead of firing a queued alarm.
At hour 121 the state becomes EXPIRED and lk.held_by is still "r1", because the box has not physically moved. The final line calls the sweep again and gets False: idempotency, demonstrated rather than asserted.
Every one of those assertions runs in microseconds because the clock is an argument.
The hold window is the capacity knob
The hold length looks like a customer-experience setting. It is really the number that decides how many packages a bank of a fixed size can serve in a day.
60 * 24 / hours is doors times hours in a day, divided by how long each door is tied up.
lockers in the bank 60
hold window, hours 72
throughput if every hold runs full 60 * 24 / 72 = 20 packages/day
observed mean dwell, hours 14
throughput at mean dwell 60 * 24 / 14 = 102.9 packages/day
Dwell is how long a package actually sits in the locker before someone collects it. In practice it is far shorter than the deadline allows.
The first calculation is what the bank could do if every recipient used every hour they were given: 20 packages a day. The second is what it does when they behave normally: 103.
That is a 5x swing in the number of packages a bank can serve, controlled entirely by a policy constant. It is why “extend the deadline” is a pricing question rather than a user-interface question, and why HOLD_HOURS, MAX_EXTENSIONS and EXTENSION_HOURS belong on a policy object owned by each bank rather than staying module constants.
The sweep costs nothing
Sixty reservations scanned every five minutes is 288 scans a day (24 hours × 60 minutes ÷ 5) over a list of sixty objects. That is small enough to sit entirely in the processor’s fastest cache.
Compare that to one scheduled timer per package, which has to be created, persisted, and cancelled every time a deadline moves.
Decision 3 — access codes: scope before length
The deadline is set; the recipient still needs a way through the door. That way is a six-digit code, and how safe it is turns on a modelling decision made long before anyone counts digits.
Six digits, worked out
A 6-digit code feels short. Whether it is depends on what it is scoped to: how many doors a single guessed number could open.
Work it out for the weakest sensible design: one shared keypad, and any live code opens its own door.
code space 10 ** 6 = 1,000,000
live codes, one bank 60
p(random guess opens a door) 60 / 1,000,000 = 0.00006
expected guesses to a hit 1 / 0.00006 = 16,667
at 3 attempts per hour, hours 16,667 / 3 = 5,556
in days 5,556 / 24 = 231
Line by line. Six digits give a million possible codes. Sixty live reservations mean sixty of that million are currently valid, so a random guess succeeds with probability 0.00006, six in a hundred thousand.
The reciprocal of that probability, 16,667, is the expected number of guesses before the first success. Throttling the keypad to three attempts an hour turns those guesses into 5,556 hours, 231 days of continuous attack for one expected hit.
Scope beats length
And 231 days is the weak version, with codes scoped to the whole bank.
Scope the code to one door instead (the keypad asks for a locker number first, then a code, and the code is verified against that locker only) and each guess is worth 1 / 1,000,000 rather than 60 / 1,000,000. Sixty times less. Same six digits.
The length of the code is the boring lever; the scope of the code is the important one.
Per-door scoping also removes an enumeration oracle: a system that answers a question you were not supposed to be able to ask. With a bank-wide keypad, a valid code tells the attacker not only that the code was right but which door it opens, information they did not have and did not work for.
Three properties that live on the object
Three more properties follow, each a field on AccessCode rather than a policy in somebody’s head.
| Property | Value | Why |
|---|---|---|
| Stored form | salted hash | the code is a bearer credential; a database dump should not be a master key |
uses_left | 1 for a single-package reservation, n for a multi-package one | a code that opens the door twice lets the next recipient reach in |
expires_at | the reservation deadline, not later | an expired package is not the recipient’s to collect |
Two terms in that table need unpacking.
A bearer credential is a secret that works for whoever presents it, with no further proof of identity — like a cinema ticket, and unlike a password paired with a username. That is why the stored form matters so much: anyone who reads the database can walk up and open doors.
A salted hash is the standard defence, and it is two ideas stacked:
- A hash is a one-way scramble. Easy to compute forwards, infeasible to reverse. The stored value can prove a guess is right without containing the code.
- A salt is a random string mixed in before hashing and stored alongside the result. It stops an attacker from pre-computing the hashes of all million codes once and then reading every locker in the country from a single table.
Here is AccessCode. Four fields, three methods. In verify, the order of the two checks is the point.
from __future__ import annotations
import hashlib
import hmac
import secrets
@dataclass
class AccessCode:
digest: str
salt: str
uses_left: int
expires_at: datetime
@staticmethod
def _hash(code: str, salt: str) -> str:
return hashlib.sha256((salt + code).encode()).hexdigest()
@classmethod
def mint(cls, expires_at: datetime, uses: int = 1) -> tuple[AccessCode, str]:
code = f"{secrets.randbelow(10 ** 6):06d}"
salt = secrets.token_hex(8)
return cls(cls._hash(code, salt), salt, uses, expires_at), code
def verify(self, attempt: str, clock: Clock) -> bool:
if self.uses_left <= 0 or clock.now() >= self.expires_at:
return False
if not hmac.compare_digest(self.digest, self._hash(attempt, self.salt)):
return False
self.uses_left -= 1
return True
The security decisions hiding in the idioms
Four lines in that block look like Python trivia and are not.
secrets.randbelowdraws from the operating system’s cryptographic random source rather than the ordinaryrandommodule, whose sequence is predictable from a seed. A predictable code generator makes the 231-day figure above a fiction.f"{...:06d}"formats the number with leading zeros to exactly six digits, so42becomes"000042". Without the padding it would render as"42", a two-character code that tells an attacker the number was small and shrinks the space they have to search.hashlib.sha256(...).hexdigest()computes the one-way scramble and renders it as text;.encode()turns the string into the bytes the hash function consumes.secrets.token_hex(8)produces the random salt.hmac.compare_digestcompares two strings in constant time, meaning the comparison takes the same time whether the strings differ in the first character or the last. Python’s ordinary==stops at the first difference, and an attacker who can measure that difference can recover a secret one character at a time (a timing attack). Comparing digests, not codes, and comparing them in constant time, is a habit worth showing.
Two more decorators appear there. mint is a class method: it is called on the class rather than on an instance (AccessCode.mint(...)), and cls is the class itself, so cls(...) builds the object. It returns a pair so the plaintext code has exactly one path out of the system, to the recipient, and is never stored.
_hash is a static method: a plain function that lives inside the class for namespacing and takes no self. The leading underscore is Python’s convention for “internal, do not call from outside”, the same convention as _lock later on.
The tests below make three claims: a wrong code fails, a right code works exactly once, and an expired code fails even when it is right.
clock = FrozenClock(datetime(2026, 3, 1, 9, 0))
code_obj, plaintext = AccessCode.mint(clock.now() + timedelta(hours=72))
assert code_obj.verify("000000" if plaintext != "000000" else "111111", clock) is False
assert code_obj.verify(plaintext, clock) is True
assert code_obj.verify(plaintext, clock) is False # single use, consumed
expiring, pt2 = AccessCode.mint(clock.now() + timedelta(hours=1))
clock.advance(2)
assert expiring.verify(pt2, clock) is False # expiry beats a valid code
The first assertion tries a code guaranteed to be wrong. The conditional expression picks "000000", unless that happens to be the real code, in which case it picks "111111". Either way it is refused.
The correct code then succeeds once and is refused on every attempt after that, because the single use was consumed.
The last pair shows the ordering that matters: a valid code presented after the deadline is still refused, because verify checks expiry before it checks the digest.
Two counters, not one
uses_left decrements on a correct code, never on an attempt: the uses_left -= 1 line sits after both early returns.
Rate limiting (capping how many guesses a keypad accepts in an hour, where the 231-day figure came from) is a separate counter, and it belongs on the keypad.
Conflate the two and an attacker can burn a legitimate recipient’s code by guessing at it.
Extension scenarios
Each of the three requirement changes below is the kind an interviewer springs at minute thirty-five. What is measured is not whether you can code it, but how many files the change touches.
Each one follows the same shape: what changes, what the design did to make that cheap, and what it cost.
“Now support oversized packages that need two adjacent lockers”
What changes: nothing in Reservation, because it already holds list[Locker] rather than a single locker. The whole feature is one new policy class.
The policy below looks for two free doors that are physically side by side. The if condition is three separate requirements joined by and.
class AdjacentPair(AllocationPolicy):
"""Two free lockers in the same bay at consecutive slots, opened as one."""
def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]:
by_slot = sorted((lk for lk in lockers if lk.free),
key=lambda lk: (lk.bay_id, lk.slot))
for a, b in zip(by_slot, by_slot[1:]):
if a.bay_id == b.bay_id and b.slot == a.slot + 1 \
and min(a.size, b.size) >= Size.MEDIUM:
return [a, b]
return []
Three things in that method need naming.
zip(by_slot, by_slot[1:]) is the standard Python idiom for walking a list in consecutive pairs: it pairs each element with the one after it, so a four-item list yields three pairs.
Sorting by (bay_id, slot) first makes those pairs neighbours in the physical cabinet rather than neighbours in an arbitrary list order.
The explicit a.bay_id == b.bay_id check throws away the one bad pair the sort creates: the last locker of one bay sitting next to the first locker of the next, which are on opposite ends of the lobby.
The test uses three lockers: two side by side in bay A, one alone in bay B.
bay = [Locker("A0", "A", 0, Size.MEDIUM), Locker("A1", "A", 1, Size.MEDIUM),
Locker("B0", "B", 0, Size.MEDIUM)]
big = Package("oversized", Size.LARGE)
assert [lk.locker_id for lk in AdjacentPair().select(bay, big)] == ["A0", "A1"]
bay[1].held_by = "someone" # break the run
assert AdjacentPair().select(bay, big) == [] # B0 has no neighbour: correct
The second half of that test is the interesting half. Occupying A1 leaves A0 and B0 free. They are adjacent in the list and not adjacent in the lobby, and the policy correctly finds nothing.
The limitation: AdjacentPair never calls fits, so it ignores pkg.needs and pkg.size. It is deliberately the narrow policy for “this package is too big for one door”, and a production bank would run it only after the single-door policies came back empty. Say that rather than letting an interviewer find it.
Why the design absorbed this for free
slot was modelled on Locker from the start, and select returned a list rather than an optional single locker. Neither choice cost anything when it was made.
Had select returned Locker | None (the signature most candidates write, meaning “a locker, or nothing”), this extension would edit the abstract base class, both existing policies, the caller, and Reservation.lockers.
That is the five-files-for-one-requirement failure, caused by a return type, not by a missing pattern. Return a collection from an allocator whenever “more than one” is imaginable. The cost of the plural signature is that every caller writes chosen[0] in the common case, which is real and much smaller.
Claiming two lockers has to be atomic
Atomic means the whole claim either happens or does not, with no state in between that another caller can observe.
The race: two couriers arrive with oversized packages. Both see A0, A1 free. Both call select. Both get the same pair. Both write. One package is now sitting in a door the system believes belongs to the other.
The fix is that the check and the claim happen inside one guarded step, not as two separate operations. That is what LockerBank is for, and it is where the diagram’s claim and release get bodies.
import threading
class LockerBank:
def __init__(self, lockers: list[Locker], policy: AllocationPolicy):
self.lockers, self.policy = lockers, policy
self._lock = threading.Lock()
def claim(self, res_id: str, pkg: Package) -> list[Locker]:
with self._lock: # select + write under one lock
chosen = self.policy.select(self.lockers, pkg)
for lk in chosen:
lk.held_by = res_id
return chosen
def release(self, lockers: list[Locker]) -> None:
"""Give doors back to the pool.
Called on pick-up or on courier retrieval -- never on expiry, because
an expired package is still physically inside the door.
"""
with self._lock:
for lk in lockers:
lk.held_by = None
A threading.Lock is a token that only one thread can hold at a time. with self._lock: takes it on entry to the block and releases it on exit, including if an exception is raised.
Selecting outside the lock and writing inside it would be the bug. The check and the claim must not be separable, or a second courier slips between them. This is the classic check-then-act race.
Run it once:
bank = LockerBank([Locker("A0", "A", 0, Size.MEDIUM),
Locker("A1", "A", 1, Size.MEDIUM)], AdjacentPair())
claimed = bank.claim("r9", Package("oversized-2", Size.LARGE))
assert [lk.locker_id for lk in claimed] == ["A0", "A1"]
# Both doors are now taken, so the next oversized package is refused.
assert bank.claim("r10", Package("oversized-3", Size.LARGE)) == []
bank.release(claimed) # courier retrieved it
assert all(lk.held_by is None for lk in claimed)
assert len(bank.claim("r11", Package("oversized-4", Size.LARGE))) == 2
The r10 line shows a full bank answering []: the “an empty list is an answer, not an error” contract from the top of the chapter. The last line shows the doors coming back into the pool after release.
The same guarantee across several processes. In a deployment with more than one server there is no shared in-memory lock, so the guarantee comes from a conditional database update instead:
UPDATE lockers SET held_by = ? WHERE locker_id IN (?, ?) AND held_by IS NULL
Then verify that the number of rows changed is 2, and roll back if it is not. The database refuses to update a row somebody else already claimed, which is the same “check and act in one indivisible step” in another medium.
“Now allow a recipient to extend the deadline”
What changes: nothing. The extend method above already does it.
The deadline is data on the reservation, and expiry is recomputed from that data on every sweep. An extension is one field write, and the sweep is correct on its next run with no coordination. The hour-73 assertion in Decision 2 is this feature already passing its test.
What it would have cost under a timer-per-package design: cancel the pending timer, schedule a new one, and handle the case where the cancellation loses the race against the timer firing. In that case the package is returned to sender despite a paid extension, and the customer-facing bug is unreproducible because it depends on millisecond timing.
Prefer recomputed state over scheduled state whenever the schedule can move.
What does change is smaller. MAX_EXTENSIONS and EXTENSION_HOURS are module constants in this sketch. They belong on a HoldPolicy object owned by each bank, because a locker bank in a hospital lobby and one in an apartment block do not have the same answer. That is the same Strategy shape as allocation, introduced only when the second bank appears.
“Now add refrigerated lockers”
What changes: the data, and one line of the allocation preference.
The wrong move first
The wrong move is class RefrigeratedLocker(Locker).
Refrigeration is not a kind of locker. It is a capability a locker has. Subclass it and the next capability (charging port, oversized, secure-for-pharmacy) multiplies into RefrigeratedOversizedLocker and then into every other combination.
Capabilities compose; subclasses multiply.
Feasibility is not preference
Locker.features and Package.needs are already sets, so pkg.needs <= lk.features is the whole fit change. fits() does not move at all.
But the allocator is now wrong in a way that fits cannot see. A refrigerated locker fits an ordinary package perfectly well, so SmallestFit hands a scarce chilled door to a paperback and then rejects the grocery delivery that arrives ten minutes later.
Two different questions have been conflated:
- Feasibility — can this package go here? That lives in
fits. - Preference — should it? That lives in the policy, and nowhere else.
The policy below answers the second question with a three-level sort key.
class ScarcityAware(AllocationPolicy):
"""Feasible, then cheapest: never spend a capability the package did not ask for."""
def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]:
usable = [lk for lk in lockers if lk.fits(pkg)]
if not usable:
return []
return [min(usable, key=lambda lk: (len(lk.features - pkg.needs),
lk.size, lk.slot))]
The key function returns a tuple, and Python compares tuples left to right: it decides on the first element and only looks at the second when the first ties. So the three terms are three ranked rules.
len(lk.features - pkg.needs)counts the capabilities this locker has that the package did not ask for. Set subtraction gives the leftover features, so a plain door scores 0 and a chilled door scores 1. The plain door wins.lk.sizebreaks ties among equally wasteful doors: smallest fitting door.lk.slotbreaks ties among equal sizes. It exists purely to make the answer deterministic and therefore testable.
Two lockers, two packages, three assertions. The first two compare the old policy against the new one on the same package.
cold = Locker("C1", "A", 0, Size.SMALL, frozenset({"chilled"}))
plain = Locker("P1", "A", 1, Size.SMALL)
book = Package("book", Size.SMALL)
milk = Package("milk", Size.SMALL, frozenset({"chilled"}))
assert SmallestFit().select([cold, plain], book)[0].locker_id == "C1" # wastes it
assert ScarcityAware().select([cold, plain], book)[0].locker_id == "P1"
assert ScarcityAware().select([cold, plain], milk)[0].locker_id == "C1"
The three assertions are the whole argument:
SmallestFitburns the chilled door on a book, because both doors areSMALLand the chilled one happens to come first on slot order.ScarcityAwaresaves the chilled door and gives the book the plain one.ScarcityAwarestill hands the chilled door to the milk, which is what the door is for.
What this cost: the sort key is now three levels deep, and the reason for each term lives in prose beside the code rather than in a name inside it.
That is the price of a preference function, and it is why “add a new locker feature” is a data change while “add a new reason to prefer a locker” is a code change. A design that made both free would be a rules engine (a general system for evaluating configurable rules), and a rules engine in an object-oriented design interview is a candidate solving a problem nobody asked about.
What interviewers probe
These are the questions this design exists to answer. The right-hand column is the actual answer, not a summary of it.
| Probe | The answer that lands |
|---|---|
| “What happens at hour 73?” | a sweep flips AWAITING_PICKUP -> EXPIRED; the locker stays held because the box is still in it; a courier route picks it up and only then is the door freed |
| “How do you test that?” | inject the clock, advance it 73 hours, assert. If the answer involves sleeping or patching the system clock, the design already lost |
| “Two couriers, one locker” | select-and-claim under one lock, or a conditional update with a row-count check — never check-then-write |
| “Is 6 digits enough?” | scope first: per-door verification makes each guess worth 1 / 1,000,000; then rate limit; then length |
| “Reuse the same code for a second package?” | only with uses_left > 1 minted deliberately; the default is single-use, and PICKED_UP is terminal |
| “Where does Singleton go?” | nowhere. A Singleton is a class rigged so only one instance can exist; LockerBank looks like one until the second bank ships, and a global clock is exactly the thing that makes expiry untestable. Both are constructor arguments — see ch 03 |
| “Why not model real dimensions?” | the doors are discrete; quantizing turns three-dimensional packing into an ordinal comparison. Offer the packing version if they want it |
Cheat sheet
One line per idea, in the order you would draw them. If you can restate the right-hand column from the left-hand label alone, you can redraw the model.
| Aggregate root | Reservation — owns state, deadline, code; refers to a package; holds lockers |
| Composition | Bay *-- Locker (slot identity), Reservation *-- AccessCode |
| Aggregation | Reservation o-- Locker — the wall outlives the delivery |
| Strategy | AllocationPolicy: ScanOrder is wrong, SmallestFit is the default, AdjacentPair and ScarcityAware are the extensions |
| State machine | RESERVED -> AWAITING_PICKUP -> PICKED_UP / -> EXPIRED -> RETURNED_TO_SENDER |
| The non-obvious rule | EXPIRED does not free the locker; retrieval does |
| Clock | injected Protocol; the sweep is a function of the reservations and the time, never a timer per package |
| Capacity | 60 * 24 / 72 = 20 packages/day at full holds, 102.9 at a 14-hour mean dwell |
| Code security | scope to a door before lengthening; salted hash; uses_left; expiry beats a valid code |
| Concurrency | select and claim under one lock, or UPDATE ... WHERE held_by IS NULL and check the row count |
| Say out loud | “Reserved-but-empty and occupied are different states with different timeouts” |
| Trap | modelling refrigeration as a subclass; capabilities compose, subclasses multiply |
Two links for depth, not needed to follow anything above. The interview method (how to spend the 45 minutes) is ch 02, and the pattern vocabulary is ch 03.
The next chapter takes the same state-machine discipline into a system where a wrong transition costs real money: ch 13 — ATM.