InterviewPrepKit

Home / Learn / Object-Oriented Design

How to design a package-locker system

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:

  1. Allocation. The rule that picks a locker is a swappable policy object, not a loop buried in a method.
  2. 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.
  3. 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.

DecisionFailure it avoids
Pick the smallest locker that fits, not the first that fitsa first-fit rule rejects packages the bank had room for
Expiry is a sweep over an injected clockreading the OS clock inside the domain makes the rule untestable
Access codes are scoped, hashed, and expiringa 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:

  • claim returns 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 clock argument 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.

QuestionAnswered yesAnswered 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 baya 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 madethe 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 arrivingoccupancy is a single boolean flag
Who physically removes an expired package?a Courier actor with its own authentication path and its own state transitionexpiry 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”.

ActorUse caseThe state it moves
Courierreserve a locker, deposit a package-> RESERVED, -> AWAITING_PICKUP
Recipiententer a code, open a door, take the package-> PICKED_UP
Recipientextend the deadlinedeadline moves, state does not
Clock (the system itself)expire an overdue package-> EXPIRED
Courierretrieve 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.

ObjectResponsibilityExplicitly not its job
Lockersize, features, in-service flag, current holder idknowing about deadlines
Bayan ordered run of lockers, which is what makes “adjacent” meaningfulallocation
LockerBankthe inventory, and claiming lockers in one indivisible stepchoosing which locker
AllocationPolicychoosing which lockerknowing about reservations
Reservationstate, deadline, code, and the transitions between themopening doors
Clockthe current time, and nothing elseanything 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 writing List<Locker>, a list of Locker objects. 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: 1 is 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 LockerBank composes one or more Bays, and each Bay composes one or more Lockers. Scrapping the bank scraps the cabinets and doors with it.
  • The bank holds a reference to exactly one AllocationPolicy and 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 Reservation holds one or more Lockers without owning them, refers to exactly one Package, and composes exactly one AccessCode. The code is meaningless once the reservation is gone, so it dies with it.
  • Reservation and AccessCode both depend on Clock, handed in as a method argument every time.
  • ScanOrder, SmallestFit, AdjacentPair, and ScarcityAware each realize the AllocationPolicy contract, so any of them can be dropped into the bank without the bank noticing.

Two arrows carry the load:

  • Reservation o-- Locker is aggregation because destroying the reservation must not destroy the locker. Hardware bolted to a wall 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.

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:

  • 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), the only thing Bay was there for.
  • Two ownership arrows are not wired up. Reservation *-- AccessCode and LockerBank o-- Reservation are real design claims, but the code never stores the pointers: AccessCode is 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_up has no Python here. It is the one transition with no implementation, because it needs AccessCode, 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 variesHow the design absorbs itWhat getting it wrong would cost
Which locker a package goes intothe AllocationPolicy interface, chosen per bankthe rule is a loop inside claim, and a pharmacy’s bank needs a fork of the bank class
How many lockers one delivery usesselect returns a list; Reservation.lockers is a listoversized packages edit five files
What time it isthe injected Clockthe expiry rule is untestable, so the commercially important rule ships unverified
What a locker can do beyond holding a boxfeatures and needs are sets of strings, so a new capability is dataa subclass per capability, multiplying into RefrigeratedOversizedLocker
How long a hold lasts and how far it extendsconstants now, a HoldPolicy object when the second bank appearsa 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 fits a 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.package is 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, Size disappears, fits becomes 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, Locker carries (row, column) and AdjacentPair becomes a neighbour query against the grid. Nothing else moves, which is the tell that slot was the right place for the assumption.
  • If the recipient booked the locker instead of the courier, the reservation would exist before the package does, package would become optional, and RESERVED would need the timeout this design already gives it under the name ABANDONED.
  • 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 annotations keeps annotations as text, so str | None works on Python before 3.10 and Locker can name list[Locker] before the class finishes defining.
  • IntEnum members are also integers, so Size.SMALL < Size.LARGE is true and pkg.size <= self.size is the entire volume test.
  • @dataclass generates the constructor, equality, and string form from the fields; frozen=True on Package makes it immutable, right for facts like a tracking id.
  • frozenset is an immutable set, and pkg.needs <= lk.features is 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.
  • @property makes free read like a field while staying computed, so it can never drift out of sync with in_service and held_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.

FromEventGuardTo
RESERVEDcourier depositscode minted, door closedAWAITING_PICKUP
RESERVEDsweepnow >= reserve_deadlineABANDONED (lockers released)
AWAITING_PICKUPcorrect codenow < deadline and uses_left > 0PICKED_UP
AWAITING_PICKUPrecipient extendsextensions_used < capAWAITING_PICKUP (deadline moves)
AWAITING_PICKUPsweepnow >= deadlineEXPIRED
EXPIREDcourier retrievescourier credentialRETURNED_TO_SENDER (lockers released)
PICKED_UPanyterminal
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:

  • EXPIRED does 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.RESERVED uses is, not ==. Enum members are singletons, and identity comparison cannot be fooled by a same-looking value from somewhere else.
  • sweep is idempotent: calling it repeatedly has the same effect as calling it once. The second call finds the state already EXPIRED and returns False, 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 timeWhat happensDeadline after
1 Mar 09:00 (hour 0)courier deposits4 Mar 09:00 (+72 h)
hour 71sweep finds nothing4 Mar 09:00
hour 71recipient extends once6 Mar 09:00 (+48 h)
hour 73sweep finds nothing — past the old line6 Mar 09:00
hour 73second extend refused, cap is 16 Mar 09:00
hour 121sweep fires, state becomes EXPIRED6 Mar 09:00
hour 121locker 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.

PropertyValueWhy
Stored formsalted hashthe code is a bearer credential; a database dump should not be a master key
uses_left1 for a single-package reservation, n for a multi-package onea code that opens the door twice lets the next recipient reach in
expires_atthe reservation deadline, not lateran 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.randbelow draws from the operating system’s cryptographic random source, not the ordinary random module, whose sequence is predictable from a seed. A predictable generator makes the 231-day figure a fiction.
  • f"{...:06d}" pads to exactly six digits, so 42 becomes "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_digest compares 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_id check 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:

  1. 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.
  2. lk.size breaks ties among equally wasteful doors: smallest fitting door.
  3. lk.slot breaks 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:

  • Reservation is 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 EXPIRED does 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 NULL with 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

ConceptIn this design
Aggregate rootReservation — owns state, deadline, code; refers to a package; holds lockers
CompositionBay *-- Locker (slot identity), Reservation *-- AccessCode
AggregationReservation o-- Locker — the wall outlives the delivery
StrategyAllocationPolicy: ScanOrder wrong, SmallestFit default, AdjacentPair and ScarcityAware extensions
State machineRESERVED -> AWAITING_PICKUP -> PICKED_UP / -> EXPIRED -> RETURNED_TO_SENDER
Non-obvious ruleEXPIRED does not free the locker; retrieval does
Clockinjected 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 securityscope to a door before lengthening; salted hash; uses_left; expiry beats a valid code
Concurrencyselect and claim under one lock, or UPDATE ... WHERE held_by IS NULL with a row-count check
Trapmodelling 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.
Report a bug