A parcel-locker bank holds a package until pickup and gives the locker back when nobody collects; the design turns on expiry, not storage.
Three decisions
| Decision | Failure it avoids |
|---|---|
| Pick smallest fitting locker, not first fit | first-fit rejects packages the bank had room for |
| Expiry is a sweep over an injected clock | reading the OS clock in the domain is untestable |
| Access codes scoped, hashed, expiring | a 6-digit code that opens any door = 60 live doors in 1,000,000 |
Core objects
Reservation is the aggregate root: the object outside code talks to, owning the state machine, deadline, and access code. Promoting the delivery to an object separates three mismatched lifetimes: hardware (years), delivery (hours), shipment (before drop-off, after return).
| Object | Owns | Not its job |
|---|---|---|
Locker | size, features, in-service, held_by | deadlines |
Bay | ordered run of lockers (adjacency) | allocation |
LockerBank | inventory, atomic claim | choosing a locker |
AllocationPolicy | choosing a locker | reservations |
Reservation | state, deadline, code, transitions | opening doors |
Clock | current time, nothing else | anything else |
Sizeis an ordered enum (SMALL < MEDIUM < LARGE), sofitsis one<=. Continuous dimensions would force 3D bin packing.held_by= reservation id occupying a door, orNonewhen free.free = in_service and held_by is None.- Relationships:
Bay *-- Locker(composition, slot identity),Reservation o-- Locker(aggregation, wall outlives delivery),Reservation *-- AccessCode(composition).
Decision 1 — allocation is a Strategy
- Strategy pattern: pull the varying rule into a swappable object with a fixed
select(lockers, pkg) -> list[Locker]; return[]when nothing fits (an answer, not an error). ScanOrder(first fit) hands a small package the large door, then rejects a large package the bank had room for.SmallestFitpreserves scarce big doors and rejects nothing.- Return a list, so oversized packages spanning two doors need no signature change.
- Cost: which locker you get is invisible in a stack trace; a
ScanOrder-only bug won’t reproduce under the default.
Decision 2 — lifecycle on an injected clock
State machine (six states, closed):
| From | Event | To |
|---|---|---|
RESERVED | deposit | AWAITING_PICKUP |
RESERVED | sweep, deadline passed | ABANDONED (released) |
AWAITING_PICKUP | correct code | PICKED_UP (terminal) |
AWAITING_PICKUP | extend | AWAITING_PICKUP (deadline moves) |
AWAITING_PICKUP | sweep, deadline passed | EXPIRED (stays held) |
EXPIRED | courier retrieves | RETURNED_TO_SENDER (released) |
EXPIREDdoes not free the locker — the box is still inside; only courier retrieval releases it. Freeing at expiry double-books the door.- Time enters via one injected
Clock(aProtocol); tests pass aFrozenClock. A 72-hour rule can’t be tested by waiting. - Expiry is a sweep (function over all reservations + now), not one timer per package. Nothing queued means nothing to cancel when a deadline moves, which makes “extend” free.
sweepis idempotent: second call findsEXPIRED, returnsFalse— safe on a timer that double-fires.extendadds todeadline, notnow()— adding to now lets a prompt recipient shorten their own hold.- Guards raise on illegal transitions; use
isnot==on enum singletons. - Capacity: throughput = doors × 24 / dwell-hours. A 60-door bank serves ~20/day at full 72h holds, ~103/day at a 14h mean dwell. Hold length is the capacity knob, so
HOLD_HOURSbelongs on a per-bank policy.
Decision 3 — access codes: scope before length
- Safety depends on scope (how many doors one guess opens), not digit count. Bank-wide keypad: 60 live codes in 1,000,000, guess worth 60/1,000,000. Per-door scope: 1/1,000,000, 60x weaker, same six digits. Per-door scoping also removes an enumeration oracle.
- Three fields on
AccessCode: stored as a salted hash (a bearer credential; a DB dump must not be a master key),uses_left(1 per package; a reusable code lets the next recipient reach in),expires_at(= reservation deadline). verifychecks expiry anduses_leftbefore the digest, and decrements only on a correct code.- Security idioms:
secrets.randbelow(crypto RNG, notrandom), zero-pad to 6 digits,hmac.compare_digest(constant time, defeats timing attacks). - Two counters:
uses_left(on the code) vs rate limiting (on the keypad, source of the ~231-day figure). Conflating them lets an attacker burn a legit code by guessing.
Extensions and gotchas
- Oversized: one new
AdjacentPairpolicy;Reservationalready holds a list. Free becauseslotexisted andselectreturned a list. Sort by(bay_id, slot), reject the cross-bay pair. - Extend deadline: nothing changes — deadline is data, expiry recomputes each sweep. Prefer recomputed state over scheduled state.
- Refrigeration: a feature flag in
Locker.features/Package.needs, not aRefrigeratedLockersubclass. Capabilities compose; subclasses multiply. Feasibility lives infits; preference (don’t waste a chilled door on a book) lives in the policy (ScarcityAware). - Claims must be atomic: select + write under one lock, or
UPDATE lockers SET held_by=? WHERE ... AND held_by IS NULLwith a row-count check. This is the check-then-act race. - Traps:
LockerBankis not a Singleton (second bank ships); a global clock is what makes expiry untestable. Both are constructor arguments.