In this lesson, we’ll design the object model for a bank of parcel lockers: the classes and the relationships between them. The interesting object is not the locker but the three-day hold, so the design turns on how a locker is handed back when nobody collects. By the end you’ll be able to pick a locker with a swappable allocation policy, test a three-day expiry rule in microseconds by injecting the clock, and explain why a six-digit access code is safe or weak for a reason unrelated to its length.
What this system is
A parcel locker bank is the metal cabinet in a supermarket lobby. A courier opens a door, puts a parcel in, and shuts it. The recipient gets a code by text, walks up, types the code, and takes the parcel. If nobody collects within three days, the courier returns the parcel to the sender.
This lesson designs the object model for that cabinet: the classes and the relationships between them. Three problems carry the weight:
- Allocation. The rule that picks a locker is a swappable policy object, not a loop buried in a method.
- Lifecycle. The expiry rule is testable in microseconds instead of three days, because the current time is passed in as an argument instead of read from the operating system.
- Access codes. A six-digit code is safe or weak depending on a modelling decision unrelated to its length.
The interesting object is not the locker; it is the three-day hold. Sizing and allocation have a clean answer. The harder question is what happens at hour 73, and answering it without a scheduled background job nobody can test is what shapes the design. A locker holding a package nobody collected serves no one, so expiry is not an edge case here. It is the capacity model.
Three decisions that shape the design
Each decision exists to avoid a specific failure.
| Decision | Failure it avoids |
|---|---|
| Pick the smallest locker that fits, not the first that fits | a first-fit rule rejects packages the bank had room for |
| Expiry is a sweep over an injected clock | reading the OS clock inside the domain makes the rule untestable |
| Access codes are scoped, hashed, and expiring | a 6-digit code that opens any door is 60 live doors in 1,000,000 |
The ask
Stripped to one sentence:
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, with no human remembering to, is what the classes have to be arranged for.
The public surface
Five operations make up the entire public interface. The sketch below names each call and what it returns. It is not runnable code; every name is defined later. Two of the return values are design decisions, not details.
IN bank.claim("r1", Package("p1", Size.SMALL))
OUT a LIST of lockers, each now marked held_by="r1".
An empty list [] means nothing fit -- an ANSWER, not an error: the
courier is told to try another bank.
IN reservation.deposit(clock)
OUT nothing returned. The observable change: state is now AWAITING_PICKUP
and the deadline is 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. Only a hash of the plaintext is ever stored.
(plaintext = the code as the human reads it, "418902".)
IN code.verify("418902", clock)
OUT True or False. On True the 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, else False. Safe to
call repeatedly; only the first one moves.
held_by is a field on a Locker holding the id of the reservation occupying that door, or None when the door is free. It is the only mark of occupancy in the design.
Two return types are decisions, not details:
claimreturns a list of lockers, not one, so an oversized package can occupy two doors later without changing the signature.- Every method that cares about time takes a
clockargument instead of reading the system clock, which is what makes the 72-hour rule testable.
The output of this design is mostly observable state, not returned objects. Three things are observable: which locker’s held_by names which reservation, which state the reservation is in, and what its deadline is. Every assertion in this lesson reads one of those three.
Design questions that move a class boundary
Some questions change a single field; these four each add or remove a class. 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; this system needs three.
Actors and use cases
An actor is anyone or anything that starts an interaction with the system. One of them here is not a person, and that fact reorganises the design. -> 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. Because the clock initiates a state change, it is a dependency, so it is injected: handed to the objects that need it as an argument instead of fetched from the operating system deep inside a method.
Core objects
The actor list said what the system does; the next question is which objects hold it. Every object has to clear one bar: why is it not simply a field on something else?
Why the two-class model fails
The obvious model has a Locker holding a Package and a status string. 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.
Reservation is the aggregate root
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. The design promotes the delivery itself to an object: 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 column says why each responsibility is not on its neighbour.
| 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
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) to answer a question the hardware already answered.
Class diagram
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 = Unified Modeling Language, the standard shapes for drawing software structure).
- Each box is a class. The lines inside it are its fields and methods.
- A leading
+marks a public member, visible to code outside the class. List~Locker~is mermaid’s way of writingList<Locker>, a list ofLockerobjects. Mermaid uses tildes because angle brackets would collide with HTML.- A stereotype is a 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. - The quoted numbers at the ends of a line are multiplicities:
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, not on an instance, and returns a pair.
The four line styles
The arrowheads carry claims the boxes do not:
*--composition, a filled diamond at the owner’s end. The owner controls the part’s lifetime, so destroying the owner destroys the part.o--aggregation, 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.
The arrows as sentences
- A
LockerBankcomposes one or moreBays, and eachBaycomposes one or moreLockers. Scrapping the bank scraps the cabinets and 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,AdjacentPair, andScarcityAwareeach realize theAllocationPolicycontract, so any of them can be dropped into the bank without the bank noticing.
Two arrows carry the load:
Reservation o-- Lockeris aggregation because destroying the reservation must not destroy the locker. Hardware bolted to a wall survives everything.Bay *-- Lockeris 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.
Drawing both as *-- would say the hardware is deleted when a delivery completes.
Where the diagram and the code differ
A class diagram is a model. The Python here is the shortest thing that runs and proves the arguments. Three places diverge:
Bayhas no Python class. The code flattens it into two fields onLocker,bay_idandslot, andLockerBanktakes a flatlist[Locker]. That is enough to compute adjacency (samebay_id,slotdiffering by one), the only thingBaywas there for.- Two ownership arrows are not wired up.
Reservation *-- AccessCodeandLockerBank o-- Reservationare real design claims, but the code never stores the pointers:AccessCodeis exercised standalone in Decision 3, and nothing here keeps a registry of reservations. Wiring them up would add two fields and no new argument. Reservation.pick_uphas no Python here. It is the one transition with no implementation, because it needsAccessCode, introduced two sections later. It is six lines:
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
What this class structure assumes
A class diagram is a bet about what will change. Every interface says “I expect this to vary”; every hard-coded field says “I expect this to hold forever.” The general form of the argument is in the OOP fundamentals chapter; this is the locker version.
Assumed to vary, and therefore given an interface, a parameter, or a data field:
| What varies | How the design absorbs it | What getting it wrong would cost |
|---|---|---|
| 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 |
| 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 extends | constants now, a HoldPolicy object when the second bank appears | a hospital and an apartment block are stuck with the same 72 hours forever |
Assumed fixed, and therefore baked into the structure:
- Door sizes are a short ordered list, which 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, which is why
Reservation.packageis singular. - The set of states is closed at six, so 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 note at the end of the oversized extension breaks.
What a different assumption would have produced:
- If door sizes were continuous,
Sizedisappears,fitsbecomes a geometric test, and allocation becomes three-dimensional bin packing, solved with an approximate rule plus a way to score wasted space, because the exact answer is unaffordable. - If a bay were a two-dimensional grid instead of a row,
Lockercarries(row, column)andAdjacentPairbecomes a neighbour query against the grid. Nothing else 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, andRESERVEDwould need the timeout this design already gives it under the nameABANDONED. - If the credential were a phone-generated barcode instead of typed digits, verification becomes a signature check, guessing stops being the threat, and the 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 the oversized extension.
Decision 1 — allocation is a Strategy, and the default one is wrong
Which door should a package get? The first-fit 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 instead of a fork.
First-fit 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.
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 and gets 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 preserves the scarce big doors 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 block below builds the vocabulary the rest of the lesson uses: Size, Package, Locker, the AllocationPolicy contract, and the two competing policies. The two select methods are the point of comparison: one takes the first fit, one 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 idioms there each carry a design decision, not just keystrokes:
from __future__ import annotationskeeps annotations as text, sostr | Noneworks on Python before 3.10 andLockercan namelist[Locker]before the class finishes defining.IntEnummembers are also integers, soSize.SMALL < Size.LARGEis true andpkg.size <= self.sizeis the entire volume test.@dataclassgenerates the constructor, equality, and string form from the fields;frozen=TrueonPackagemakes it immutable, right for facts like a tracking id.frozensetis an immutable set, andpkg.needs <= lk.featuresis the subset test (every capability the package needs is one this locker has) in a single operator that stays one operator no matter how many features exist.@propertymakesfreeread like a field while staying computed, so it can never drift out of sync within_serviceandheld_by.next((... for ...), [])returns the first fitting locker, or[]if none did.
ABC and @abstractmethod make AllocationPolicy impossible to instantiate; a subclass that forgets select fails when someone tries to create it, not quietly at the first call. The ... is a “no body” placeholder.
Proving the loss
rejects builds a three-locker bank, runs arrivals through a policy, and counts how many were turned away. The two assertions 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
missed += not chosen counts rejections: an empty list is falsy, so not chosen is True exactly when nothing was allocated, and True adds as 1. An assert raises 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 lesson is checked this way.
What Strategy buys and costs
Cheap: a bank next to a pharmacy holds refrigerated doors back for prescriptions; a bank in an office lobby maximises packages per day. Same code, different constructor argument.
Costly: two policies is one policy plus a decision invisible in a stack trace. A bug that only appears under ScanOrder will not reproduce in a test that builds 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 as an argument, so a three-day rule can 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.
| 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 |
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:
EXPIREDdoes not release the locker. Row five 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” an implementation bug, not an open policy question.
Time enters through one injected clock
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, so every method that needs the time receives it as an argument.
The general argument, and its cost (a clock argument threaded through every object that can expire), is made over a 120-second seat hold in the movie-booking chapter. What differs here is the horizon. A two-minute hold can be tested by waiting, badly; a 72-hour hold cannot be tested at all without an injectable clock. Untestable time stops being an inconvenience and becomes a design defect, because 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 with a now() method returning a datetime counts as a Clock, with no inheritance and no registration. 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.
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 block below has the six states, three policy constants, and Reservation with its three transitions. On the line that sets EXPIRED, the comment marks the deliberate choice to leave the lockers 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 do more than they appear:
- Every method opens with a guard that raises, instead of silently doing nothing, so an illegal transition fails loudly at the moment of the mistake instead of surfacing as a wrong state an hour later.
state is not State.RESERVEDusesis, not==. Enum members are singletons, and identity comparison cannot be fooled by a same-looking value from somewhere else.sweepis idempotent: calling it repeatedly has the same effect as calling it once. The second call finds the state alreadyEXPIREDand returnsFalse, so it is safe on a timer that occasionally fires twice.
This sweep implements row five (AWAITING_PICKUP -> EXPIRED), not row two (RESERVED -> ABANDONED). Row two needs a second deadline field set at creation and a branch of the same shape, the same mechanism twice, shown once.
Why extend adds to the deadline, not to now()
Extending from now() would give a recipient who extends one hour after drop-off 48 hours from that moment: 49 hours total instead of the 120 they were promised. They would shorten their own hold by being prompt. Adding to self.deadline 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
| 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, because it recomputes from the deadline field instead of firing a queued alarm. That is the paid extension working for free. At hour 121 the state becomes EXPIRED and lk.held_by is still "r1", because the box has not physically moved; the final sweep returns False, demonstrating idempotency. Every assertion 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 fixed-size bank can serve in a day. Throughput is doors × 24 / dwell-hours. A 60-door bank serves about 20 packages a day if every hold runs the full 72 hours, but about 103 a day at the observed 14-hour mean dwell (how long a package actually sits before collection). That 5x swing is controlled entirely by one policy constant. It is why “extend the deadline” is a pricing question, and why HOLD_HOURS, MAX_EXTENSIONS, and EXTENSION_HOURS belong on a policy object owned by each bank instead of staying module constants.
The sweep costs nothing
Sixty reservations scanned every five minutes is 288 scans a day over a list of sixty objects, small enough to sit entirely in the processor’s fastest cache. Compare 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.
Length is the boring lever
Whether six digits is short depends on what the code is scoped to: how many doors a single guessed number could open. Take the weakest sensible design: one shared keypad, and any live code opens its own door. Six digits give 10^6 = 1,000,000 possible codes, 60 of them live at once, so a random guess opens a door with probability 60/1,000,000. Throttled to three attempts an hour, one expected hit takes on the order of 231 days of continuous attack.
Scope the code to one door instead (the keypad asks for a locker number first, then verifies the code against that locker only) and each guess is worth 1 / 1,000,000 instead of 60 / 1,000,000, sixty times less, with the 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: 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 work for.
Three properties on the object
Three more properties are fields on AccessCode, not policies 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 |
A bearer credential is a secret that works for whoever presents it, with no further proof of identity, like a cinema ticket, unlike a password paired with a username. That is why the stored form matters: anyone who reads the database could walk up and open doors.
A salted hash is the standard defence, two ideas stacked:
- A hash is a one-way scramble, easy forwards and infeasible to reverse, so the stored value proves 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 reading every locker in the country from a single table.
Here is AccessCode. 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
Four idioms there look like Python trivia and are security decisions:
secrets.randbelowdraws from the operating system’s cryptographic random source, not the ordinaryrandommodule, whose sequence is predictable from a seed. A predictable generator makes the 231-day figure a fiction.f"{...:06d}"pads to exactly six digits, so42becomes"000042". Without it, a short code tells an attacker the number was small and shrinks their search.hashlib.sha256(...).hexdigest()computes the one-way scramble as text;secrets.token_hex(8)produces the random salt.hmac.compare_digestcompares in constant time, the same time whether the strings differ in the first character or the last. 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, in constant time, closes that.
mint is a class method (called on the class, cls builds the object) and returns a pair, so the plaintext has exactly one path out of the system (to the recipient) and is never stored. _hash is a static method; the leading underscore is Python’s convention for “internal, do not call from outside.”
The tests 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 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 that belongs on the keypad. Conflate the two and an attacker can burn a legitimate recipient’s code by guessing at it.
Extensions
Three requirement changes, each measured by how many files it touches. Each follows the same shape: what changes, what the design did to make that cheap, and what it cost.
Oversized packages that need two adjacent lockers
What changes: nothing in Reservation, because it already holds list[Locker]. The whole feature is one new policy class that looks for two free doors physically side by side.
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:
zip(by_slot, by_slot[1:])walks the list in consecutive pairs, so a four-item list yields three pairs.- Sorting by
(bay_id, slot)first makes those pairs neighbours in the physical cabinet, not neighbours in an arbitrary list order. - The explicit
a.bay_id == b.bay_idcheck throws away the one bad pair the sort creates: the last locker of one bay next to the first of the next, on opposite ends of the lobby.
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 is the interesting one: occupying A1 leaves A0 and B0 free, adjacent in the list but not in the lobby, and the policy correctly finds nothing.
AdjacentPair never calls fits, so it ignores pkg.needs and pkg.size. It is deliberately the narrow policy for “too big for one door”, and a production bank runs it only after the single-door policies come back empty.
Why this was free
slot was on Locker from the start, and select returned a list instead of an optional single locker. Neither choice cost anything when it was made. Had select returned Locker | None, this extension would edit the abstract base class, both existing policies, the caller, and Reservation.lockers, the five-files-for-one-requirement failure, caused by a return type. Return a collection from an allocator whenever “more than one” is imaginable; the cost is that every caller writes chosen[0] in the common case, which is real and much smaller.
The claim 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 now sits in a door the system believes belongs to the other. The fix is that the check and the claim happen inside one guarded step. That is what LockerBank is for, and 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 only one thread can hold at a time; with self._lock: takes it on entry and releases it on exit, including if an exception is raised. Selecting outside the lock and writing inside would be the bug. The check and the claim must not be separable. This is the classic check-then-act race.
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 “empty list is an answer, not an error” contract. The last line shows the doors coming back into the pool after release.
The same guarantee across processes. 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
Verify that two rows changed and roll back if not. The database refuses to update a row somebody else already claimed, the same “check and act in one indivisible step” in another medium.
Extending the deadline
What changes: nothing. The extend method already does it. The deadline is data on the reservation, and expiry is recomputed from that data on every sweep, so an extension is one field write and the next sweep is correct with no coordination. The hour-73 assertion in Decision 2 is this feature already passing its test.
Under a timer-per-package design this would mean cancelling the pending timer, scheduling a new one, and handling the case where cancellation loses the race against the timer firing: returning a package to sender despite a paid extension, an unreproducible millisecond-timing bug. Prefer recomputed state over scheduled state whenever the schedule can move.
What does change is smaller: MAX_EXTENSIONS and EXTENSION_HOURS are module constants here but belong on a HoldPolicy object owned by each bank, because a hospital lobby and an apartment block do not have the same answer. That is the same Strategy shape as allocation, introduced when the second bank appears.
Refrigerated lockers
What changes: the data, and one line of the allocation preference.
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, pharmacy-secure) multiplies into RefrigeratedOversizedLocker and every other combination. Capabilities compose; subclasses multiply.
Locker.features and Package.needs are already sets, so pkg.needs <= lk.features is the whole fit change and fits() does not move. But the allocator is now wrong in a way fits cannot see: a refrigerated locker fits an ordinary package fine, so SmallestFit hands a scarce chilled door to a paperback and then rejects the grocery delivery that arrives ten minutes later. Two 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 preference 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 returns a tuple, and Python compares tuples left to right, 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, 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 remaining ties, purely to keep the answer deterministic and testable.
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: SmallestFit burns the chilled door on a book; ScarcityAware saves it and gives the book the plain door; ScarcityAware still hands the chilled door to the milk, which is what it is for.
What this cost: the sort key is three levels deep, and the reason for each term lives in prose beside the code, not in a name. That 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), which is a much larger thing than this problem needs.
Conclusion
The load-bearing decisions:
Reservationis the aggregate root. Promoting the delivery to an object separates three mismatched lifetimes (hardware, delivery, shipment) and keeps the audit trail intact.- Expiry is a sweep over an injected clock, and
EXPIREDdoes not free the locker. The box is still physically inside until a courier retrieves it. This is the one non-obvious rule. - Allocation is a swappable Strategy. First-fit rejects packages the bank had room for; smallest-fit is the right default; oversized and scarcity-aware policies drop in without touching the bank.
- Code security is scope before length. Per-door verification makes each guess 60x weaker than a bank-wide keypad, on top of a salted hash, single use, and expiry that beats a valid code.
- Capabilities compose, subclasses multiply. Refrigeration is a feature flag, not a class.
- Claims must be atomic: select and write under one lock, or a conditional
UPDATE ... WHERE held_by IS NULLwith a row-count check.
Two patterns look tempting and are wrong here. LockerBank looks like a Singleton until the second bank ships, and a global clock is exactly what makes expiry untestable. Both are constructor arguments instead.
One line to remember: the whole design turns on giving the locker back, so expiry is a sweep over an injected clock and EXPIRED leaves the door held until a courier physically clears it.
Summary table
| Concept | In this design |
|---|---|
| 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 wrong, SmallestFit default, AdjacentPair and ScarcityAware extensions |
| State machine | RESERVED -> AWAITING_PICKUP -> PICKED_UP / -> EXPIRED -> RETURNED_TO_SENDER |
| Non-obvious rule | EXPIRED does not free the locker; retrieval does |
| Clock | injected Protocol; expiry is a sweep, never a timer per package |
| Capacity | ~20 packages/day at full 72-hour holds, ~103 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 with a row-count check |
| Trap | modelling refrigeration as a subclass; capabilities compose, subclasses multiply |
Further reading
- Gamma, Helm, Johnson, and Vlissides, Design Patterns (1994): the Strategy and State patterns used here.
- OWASP Password Storage Cheat Sheet: salted hashing, and why to store a digest instead of a bearer secret.
- The OOP fundamentals chapter: the pattern vocabulary and the “what a class structure assumes” method.
- The movie-booking chapter: the injected-clock argument over a shorter hold.
- The ATM chapter: the same state-machine discipline where a wrong transition costs real money.