InterviewPrepKit

Home / Learn / Object-Oriented Design

12 — Shipping Locker System

“Design the locker bank in the supermarket lobby. A courier drops a package in, the recipient gets a code, and they collect it within three days.”

What this chapter is

A parcel locker bank is the metal cabinet in a supermarket lobby. A delivery driver opens a door, puts your parcel in, shuts it. You get a code by text. You walk up, type the code, take the parcel.

This chapter designs the object model for that cabinet — the set of classes, plus the relationships between them. Three problems inside it carry all the weight:

  1. Allocation. How to make the rule that picks a locker a swappable policy object rather than a loop buried inside a method.
  2. Lifecycle. How to write an expiry rule you can test in microseconds instead of three days, by making the current time an argument rather than something the code goes and asks the operating system for.
  3. Access codes. Why a six-digit code is either perfectly safe or catastrophically weak depending on a modelling decision that has nothing to do with its length.

By the end you should be able to draw the class diagram from memory and defend every arrow in it, say out loud why an expired package must not free the locker it is sitting in, and show on running Python that the obvious allocation rule rejects a package the bank had room for.

You do not need to have read anything else in this repository. Every term of art is defined where it first appears.

The thing candidates get wrong

The interesting object is not the locker. It is the three days.

Sizing and allocation are a twenty-minute problem with a clean answer. What separates candidates is whether the design can answer “what happens at hour 73?” without inventing a scheduled background job that nobody can test.

A locker holding a package nobody collected is a locker that earns nothing. Expiry is not an edge case here — it is the capacity model.

The three decisions

Three decisions carry the design. The middle column is the one to internalise first — it is the failure each decision exists to avoid.

The decisionThe consequenceSection
Smallest locker that fits, not first that fitsa wrong policy rejects packages the bank had room forDecision 1
Expiry is a sweep over an injected clockasking the operating system for the time from inside the domain makes the rule untestableDecision 2
Access codes are scoped, hashed, and expiringa 6-digit code that opens any door is 60 live doors in 1,000,000Decision 3

The ask

Strip the problem to one sentence before drawing anything. The sentence tells you which object is in charge.

One line: hold a package in a locker until its recipient collects it, and give the locker back when they do not.

The second half of that sentence is the whole design. Handing a locker out is easy. Taking it back on a schedule, without a human remembering to, is what the classes have to be arranged for.

What goes in, and what comes out

Fix the shape of the system before drawing classes. Name the calls a caller makes and what each one hands back, so that later you know what the tests are allowed to assert on.

Five operations make up the entire public surface. The block below is a sketch of those signatures, not runnable code — every name in it is defined later in the chapter. Read it for the return types: two of them are decisions, not details.

IN   bank.claim("r1", Package("p1", Size.SMALL))
OUT  a LIST of lockers, e.g. [locker S1], each now marked held_by="r1".
     An empty list [] means the bank had nothing that fits. That is an
     ANSWER, not an error: the courier is told to try another bank.

IN   reservation.deposit(clock)
OUT  nothing is returned. The observable change is that the reservation's
     state is now AWAITING_PICKUP and its deadline is set to
     clock.now() + 72 hours

IN   AccessCode.mint(expires_at)
OUT  a PAIR: the AccessCode object to store, and the plaintext digits to
     text to the recipient. The plaintext is never stored anywhere; only
     a hash of it is. (`plaintext` means the code as the human reads it,
     "418902", as opposed to the scrambled form kept in the database.)

IN   code.verify("418902", clock)
OUT  True or False. On True the code's remaining-uses counter drops by one,
     so a second call with the same correct code returns False

IN   reservation.sweep(clock)
OUT  True if THIS call moved the reservation to EXPIRED, False if there was
     nothing to do. Safe to call a thousand times; only the first one moves

held_by appears twice up there, so define it now: it is a field on a Locker holding the id of the reservation currently occupying that door, or None when the door is free. It is the only mark of occupancy in the whole design.

The two signatures that are decisions

claim returns a list of lockers rather than one locker. That is what lets an oversized package occupy two doors later without editing anything (extension 1).

Every method that cares about time takes a clock argument instead of reading the system time itself. That is what makes a 72-hour rule testable (Decision 2).

Both are argued for in full later. Notice them now.

The output is mostly not a return value

The output of this design is a set of observable state changes, not a set of returned objects. Three things are observable:

Saying that out loud in an interview is how you make the code testable before you have written any of it. Every assertion later in this chapter reads one of those three.

Clarifying questions that change the design

Ask only questions whose answers move a class boundary, and say why as you ask. These four each add or delete a class rather than adjusting one — either answer commits you to a different drawing.

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

Say the third one out loud. “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 has room for two answers and this system needs three.

Actors and use cases

An actor is anyone or anything that starts an interaction with the system. Listing them is worth the minute it takes here, because one of them is not a person and that single fact reorganises the design.

The third column names the state transition each use case causes. -> 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 is what causes the transition. The clock initiates a state transition, which means the clock is a dependency, which means it is injected — that is, handed to the objects that need it as a constructor or method argument, rather than fetched from the operating system deep inside a method. That sentence is the chapter, and Decision 2 spends its length on why.

Core objects, and why those

The actor list said what the system does; the next question is which objects hold it. Every object here has to clear the same bar: why is it not simply a field on something else?

Why the obvious model fails

The obvious model has a Locker holding a Package and a status string. Two classes, done.

It fails on the first requirement change, because it fuses three lifetimes that do not match each other:

Fusing them breaks the audit question “which packages passed through locker 14 last month?” The object that knew the answer was overwritten by the next delivery, so answering now needs a separate log.

Reservation is the aggregate root

So the design promotes the delivery itself to an object.

An aggregate root is the object that outside code is allowed to talk to, and which enforces the rules for the little cluster of objects behind it. Here Reservation owns the state machine, the deadline, and the access code. It refers to a package and holds lockers that it does not own.

Six objects fall out. The right-hand column is the useful one: it is what you say when an interviewer asks why a responsibility is not on the object next door.

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

One modelling choice inside that table deserves its own defence.

Size is an ordered enumeration: a fixed list of named values with a defined order, so that SMALL < MEDIUM < LARGE is a valid comparison. It is not a (width, height, depth) triple of real measurements.

Real banks quantize door sizes — that is, 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: deciding how to place boxes of arbitrary shapes into containers without wasting space, a problem with no fast exact solution. You would be solving it to answer a question the hardware already answered for you.

State that trade explicitly, because an interviewer who wanted the packing problem will say so.

Class diagram

This is the diagram to redraw from memory — but not to absorb in one look. Read the boxes first; the lines carry claims of their own, and each arrow gets restated in plain English below.

classDiagram
    class LockerBank {
        +claim(str res_id, Package) List~Locker~
        +release(List~Locker~)
    }
    class Bay {
        +str bay_id
        +List~Locker~ lockers
    }
    class Locker {
        +str locker_id
        +str bay_id
        +int slot
        +Size size
        +frozenset features
        +bool in_service
        +str held_by
        +bool free
        +fits(Package) bool
    }
    class Reservation {
        +str res_id
        +State state
        +datetime deadline
        +int extensions_used
        +deposit(Clock)
        +extend(Clock) datetime
        +sweep(Clock) bool
        +pick_up(str attempt, Clock) bool
    }
    class Package {
        +str tracking_id
        +Size size
        +frozenset needs
    }
    class AccessCode {
        +str digest
        +str salt
        +int uses_left
        +datetime expires_at
        +mint(datetime, int) tuple
        +verify(str, Clock) bool
    }
    class AllocationPolicy {
        <<abstract>>
        +select(List~Locker~, Package) List~Locker~
    }
    class Clock {
        <<interface>>
        +now() datetime
    }

    LockerBank "1" *-- "1..*" Bay : composition
    Bay "1" *-- "1..*" Locker : composition
    LockerBank "1" --> "1" AllocationPolicy : delegates to
    LockerBank "1" o-- "0..*" Reservation : aggregation
    Reservation "1" o-- "1..*" Locker : holds, does not own
    Reservation "1" --> "1" Package : refers to
    Reservation "1" *-- "1" AccessCode : composition
    Reservation ..> Clock : injected per call
    AccessCode ..> Clock : injected per call
    AllocationPolicy <|.. ScanOrder
    AllocationPolicy <|.. SmallestFit
    AllocationPolicy <|.. AdjacentPair
    AllocationPolicy <|.. ScarcityAware

Reading the notation

This is a UML class diagram. UML is the Unified Modeling Language — the standard set of shapes for drawing software structure.

Two entries need a note. Locker.free is written as a field but is computed, not stored — it is in_service and held_by is None. AccessCode.mint is called on the class rather than on an instance, and returns a pair.

Reading the four line styles

The arrowheads carry claims that the boxes do not. Four styles appear and they mean four different things:

One special case: <|.. realization, a dashed line with a hollow triangle. The class at the tail implements the interface at the head.

Reading the arrows back as sentences

Now say each line out loud. This is the drill that gets you through the diagram question.

Two of those arrows are the ones an interviewer will push on.

Reservation o-- Locker is aggregation because destroying the reservation must not destroy the locker. The locker is hardware bolted to a wall and it survives everything.

Bay *-- Locker is composition because a locker has no meaning outside its bay. Its identity is the bay plus the slot index, and that pair is exactly what makes adjacency computable.

Draw both as *-- and you have said the hardware is deleted when a delivery completes.

Where the diagram and the Python differ

A class diagram is a model. The Python in this chapter is the shortest thing that runs and proves the arguments. They are not the same artifact, and three places where they diverge would otherwise trip you up.

Bay has no Python class. The code flattens it into two fields on Locker, bay_id and slot, and LockerBank takes a flat list[Locker]. That is enough to compute adjacency (same bay_id, slot differing by one), which is the only thing Bay was there for. Say “Bay is a class in the model and two fields in the sketch” and move on.

Two ownership arrows are not wired up in the code. Reservation *-- AccessCode and LockerBank o-- Reservation are real design claims, and the sketch simply never stores the pointers: AccessCode is built and exercised standalone in Decision 3, and nothing here keeps a registry of reservations. Wiring them up would add two fields and no new argument, which is why they are safe to leave out of a chapter this size.

Reservation.pick_up has no Python here. It is the one transition in the state machine with no implementation, because it needs AccessCode, which this chapter introduces two sections later. It is six lines, and here they are — read this as a sketch, not as a block to run:

def pick_up(res, code, attempt, clock):
    if res.state is not State.AWAITING_PICKUP:
        raise ValueError(f"cannot pick up from {res.state}")
    if not code.verify(attempt, clock):
        return False
    res.state = State.PICKED_UP     # terminal: no transition leads out
    return True                     # the caller then releases the lockers

Everything else in the diagram — every other field and every other method — is implemented and exercised below.

What this class structure assumes

A class diagram is a frozen bet about what will change. Every interface you draw says “I expect this to vary”; every field you hard-code says “I expect this to hold forever”. Naming those bets out loud is the transferable skill, because this particular locker bank will not be your job — the habit of stating your own assumptions will. The general form of the argument is What a class structure assumes; what follows is this design’s version.

Assumed to vary — and therefore given an interface, a parameter, or a data field:

What variesHow the design absorbs itWhat it would cost to have got this wrong
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, as extension 1 shows
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 can be extendedConstants now, a HoldPolicy object when the second bank appearsA hospital and an apartment block need the same 72 hours forever

Assumed fixed — and therefore baked into the structure rather than into a parameter:

What a different assumption would have produced. This is the part worth rehearsing, because it is what interviewers reach for when they want to know whether you chose the design or copied it.


Decision 1 — allocation is a Strategy, and the default one is wrong

Which door should a package get? The obvious answer rejects packages the bank had room for, and the durable fix is not a better loop — it is pulling the rule out into a swappable object, so that a second bank with different priorities is a constructor argument rather than a fork.

The naive rule loses a package

The naive allocator scans the bank in physical order and takes the first door the package fits through.

Physical order is roughly by column, and columns mix sizes, so the first fit for a small package is frequently a large door.

Two arrivals are enough to show the loss. The block below is a hand-worked trace, not runnable code — it is the same scenario the Python further down asserts on.

bank                  1 large, 1 medium, 1 small
arrivals              small, then large
scan order gives      small -> LARGE, large -> nothing free   rejected  = 1
smallest fit gives    small -> SMALL, large -> LARGE          rejected  = 0

Read it as a story. The small package arrives first. The scanner hands it the large door, because that door came first in the sweep. The large package arrives a minute later and is turned away from a bank that had a large door free when the day started.

Picking the smallest fitting door instead preserves the scarce big doors for the packages that actually need them, and rejects nothing.

Making the rule swappable

The fix is the Strategy pattern: pull the varying rule out into its own object with a fixed method signature, and let the caller choose which one to install.

The honest cost is that the flow is now one indirection away from the caller. Reading bank.claim(...) no longer tells you which locker you get, because that depends on which policy object was passed to the constructor.

The next block builds the vocabulary the whole chapter uses: Size, Package, Locker, the AllocationPolicy contract, and the two competing policies. The two select methods at the bottom are the point of comparison — one takes the first fit, one takes the smallest fit.

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import IntEnum
from typing import Protocol


class Size(IntEnum):
    SMALL = 1
    MEDIUM = 2
    LARGE = 3


@dataclass(frozen=True)
class Package:
    tracking_id: str
    size: Size
    needs: frozenset[str] = frozenset()


@dataclass
class Locker:
    locker_id: str
    bay_id: str
    slot: int                      # position within the bay: adjacency is slot +- 1
    size: Size
    features: frozenset[str] = frozenset()
    in_service: bool = True
    held_by: str | None = None

    @property
    def free(self) -> bool:
        return self.in_service and self.held_by is None

    def fits(self, pkg: Package) -> bool:
        return self.free and pkg.size <= self.size and pkg.needs <= self.features


class AllocationPolicy(ABC):
    """Returns the lockers to claim, or [] if the package cannot be housed."""

    @abstractmethod
    def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]: ...


class ScanOrder(AllocationPolicy):
    """First door it fits through, in physical order. The wrong default."""

    def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]:
        return next(([lk] for lk in lockers if lk.fits(pkg)), [])


class SmallestFit(AllocationPolicy):
    def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]:
        usable = [lk for lk in lockers if lk.fits(pkg)]
        if not usable:
            return []
        # Tie-break on slot so the choice is deterministic and therefore testable.
        return [min(usable, key=lambda lk: (lk.size, lk.slot))]

The Python idioms in that block

Six idioms there are carrying a design decision rather than saving keystrokes. If any of them is unfamiliar, this is the place to slow down — they recur for the rest of the chapter.

ABC and @abstractmethod come from Python’s abstract base class machinery. AllocationPolicy cannot be instantiated, and any subclass that forgets to write select fails loudly when someone tries to create it, rather than quietly at the first call. The ... in the body is Python’s Ellipsis literal, used here as a “no body” placeholder.

Proving the loss

Now the demonstration. rejects builds a three-locker bank, runs a list of arrivals through a policy, and counts how many were turned away. The two assertions at the bottom are the whole argument: same arrivals, different policy, different number of lost packages.

def rejects(policy: AllocationPolicy, arrivals: list[Package]) -> int:
    lockers = [Locker("L1", "A", 0, Size.LARGE), Locker("M1", "A", 1, Size.MEDIUM),
               Locker("S1", "A", 2, Size.SMALL)]
    missed = 0
    for pkg in arrivals:
        chosen = policy.select(lockers, pkg)
        missed += not chosen
        for lk in chosen:
            lk.held_by = pkg.tracking_id
    return missed


arrivals = [Package("p1", Size.SMALL), Package("p2", Size.LARGE)]
assert rejects(ScanOrder(), arrivals) == 1
assert rejects(SmallestFit(), arrivals) == 0

Two lines there need decoding.

missed += not chosen leans on two Python facts at once. An empty list is falsy, so not chosen is True exactly when nothing was allocated. And True is 1 when added to an integer. Together they count rejections.

The assert statements are the proof. An assert raises an error if its condition is false and does nothing otherwise, so a block of them that runs to completion is a passing test. That is the pattern every claim in this chapter is checked with. Here they say: the same two arrivals lose a package under ScanOrder and lose nothing under SmallestFit.

What the Strategy buys and what it costs

Cheap: the operator of a bank next to a pharmacy wants refrigerated doors held back for prescriptions. The operator of a bank in an office lobby wants as many packages through the same doors per day as possible. Same code, different constructor argument.

Costly: two policies is one policy plus a decision that is invisible in a stack trace. A bug that only appears under ScanOrder will not reproduce in a test that constructs the bank with the default.


Decision 2 — lifecycle runs on a clock you can inject

Allocation put the package in a door; now the three days begin. The reservation’s lifecycle is an explicit table of legal moves, and time enters it as an argument — the choice that lets a three-day rule be tested without waiting three days.

The state machine

A state machine is a design where an object is in exactly one named situation at a time, and where each situation allows only certain events, each moving the object to a named next situation. A guard is the extra condition that has to hold for a move to be allowed.

Written out, the reservation’s machine is a seven-row table. Read each row as a sentence: from this state, this event happens, and if this guard holds, the reservation moves to that state.

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

Two things fall out of writing it as a table rather than as prose, and both are answers you will be asked for.

EXPIRED does not release the locker. Look at row five: the To column has no “(lockers released)” note, and rows two and six do. The package is still physically inside the door. A design that frees the locker at expiry has just 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 is what makes “the code was reused an hour later” a bug in the implementation rather than an open policy question.

Nothing in the domain asks the OS what time it is

The rule that matters: nothing in the domain asks the operating system what time it is.

The domain is the set of objects that carry the business rules, as opposed to the code that wires them up. Time enters the domain through one injected object, which in practice means every method that needs the time receives it as an argument.

The general argument for that, and its honest cost — a clock argument threaded through every object that can expire — is made over a 120-second seat hold in ch 05, decision 2.

What is different here is the horizon. A seat hold of two minutes can be tested by waiting, badly. A 72-hour hold cannot be tested at all without an injectable clock, so untestable time stops being an inconvenience and becomes a design defect: the expiry rule, which is the commercially important rule in this system, would ship unverified.

The clock is a Protocol

Clock is declared as a Protocol, which is Python’s name for a structural interface: any object at all that has a now() method returning a datetime counts as a Clock, with no inheritance and no registration required.

Production passes a clock that reads the system time. Tests pass the FrozenClock below, which returns whatever instant the test last set.

class Clock(Protocol):
    def now(self) -> datetime: ...


@dataclass
class FrozenClock:
    t: datetime

    def now(self) -> datetime:
        return self.t

    def advance(self, hours: float) -> None:
        self.t += timedelta(hours=hours)

FrozenClock holds a fixed instant and moves only when the test moves it. timedelta is Python’s duration type, so self.t += timedelta(hours=hours) means “jump forward this many hours”, with the calendar arithmetic handled for you.

Expiry is a sweep, not a timer

Expiry is a sweep: a function of the reservations and the current time, run periodically over all of them. It is not one scheduled alarm per package.

Nothing is queued in advance, so nothing has to be cancelled when a deadline moves. That single property is what makes the “extend the deadline” feature free later.

The next block is the heart of the chapter: the six states, the three policy constants, and the Reservation class with its three transitions. Read sweep last and notice the comment on the line that sets EXPIRED — the lockers are deliberately left held.

from __future__ import annotations

from enum import Enum


class State(Enum):
    RESERVED = "reserved"
    AWAITING_PICKUP = "awaiting_pickup"
    PICKED_UP = "picked_up"
    EXPIRED = "expired"
    RETURNED_TO_SENDER = "returned"
    ABANDONED = "abandoned"


HOLD_HOURS = 72
MAX_EXTENSIONS = 1
EXTENSION_HOURS = 48


@dataclass
class Reservation:
    res_id: str
    package: Package
    lockers: list[Locker]
    state: State = State.RESERVED
    deadline: datetime | None = None
    extensions_used: int = 0

    def deposit(self, clock: Clock) -> None:
        if self.state is not State.RESERVED:
            raise ValueError(f"cannot deposit from {self.state}")
        self.state = State.AWAITING_PICKUP
        self.deadline = clock.now() + timedelta(hours=HOLD_HOURS)

    def extend(self, clock: Clock) -> datetime:
        if self.state is not State.AWAITING_PICKUP:
            raise ValueError(f"cannot extend from {self.state}")
        if self.extensions_used >= MAX_EXTENSIONS:
            raise ValueError("extension cap reached")
        self.extensions_used += 1
        assert self.deadline is not None
        # Extend from the deadline, not from now: extending from now would let a
        # recipient shorten their own hold by extending early.
        self.deadline += timedelta(hours=EXTENSION_HOURS)
        return self.deadline

    def sweep(self, clock: Clock) -> bool:
        """True if this call moved the reservation. Idempotent by construction."""
        if self.state is State.AWAITING_PICKUP and self.deadline is not None \
                and clock.now() >= self.deadline:
            self.state = State.EXPIRED     # lockers stay held: the box is in there
            return True
        return False

Three details in that class are doing more work than they look like they are.

Every method opens with a guard that raises. It does not silently do nothing. An illegal transition becomes a loud failure at the moment of the mistake, instead of a wrong state discovered an hour later.

state is not State.RESERVED uses is, not ==. Enum members are singletons — there is exactly one State.RESERVED object in the process — and identity comparison cannot be fooled by a same-looking value from somewhere else.

sweep is idempotent. Idempotent means calling it repeatedly has the same effect as calling it once. The second call finds the state is already EXPIRED, fails the AWAITING_PICKUP check, and returns False. That property is what makes it safe to run the sweep on a timer that occasionally fires twice.

One honest gap: this sweep implements row five of the state table (AWAITING_PICKUP -> EXPIRED) and not row two (RESERVED -> ABANDONED). Row two needs a second deadline field, reserve_deadline, set when the reservation is created, and a second branch with the same shape. It is the same mechanism twice, so the chapter shows it once.

Why extend adds to the deadline and not to now()

The comment inside extend names a real exploit.

Extending from now() rather than from the existing deadline would mean a recipient who extends one hour after drop-off gets 48 hours from that moment. That is 49 hours total instead of the 120 they were promised. They would have shortened their own hold by being prompt.

Adding to self.deadline instead makes early extension harmless.

A whole package lifetime in microseconds

Now watch a package live its entire three-day life. Each advance is a jump in simulated time; every assertion states what must be true at that instant. Nothing here sleeps.

clock = FrozenClock(datetime(2026, 3, 1, 9, 0))
lk = Locker("S1", "A", 2, Size.SMALL)
res = Reservation("r1", Package("p1", Size.SMALL), [lk])
lk.held_by = "r1"

res.deposit(clock)
assert res.deadline == datetime(2026, 3, 4, 9, 0)      # 9:00 + 72 h

clock.advance(71)
assert res.sweep(clock) is False and res.state is State.AWAITING_PICKUP

assert res.extend(clock) == datetime(2026, 3, 6, 9, 0)  # + 48 h
clock.advance(2)                                        # now hour 73, past the old line
assert res.sweep(clock) is False                        # extension held

try:
    res.extend(clock)
except ValueError as e:
    assert "cap" in str(e)

clock.advance(48)                                       # hour 121, past the new line
assert res.sweep(clock) is True
assert res.state is State.EXPIRED
assert lk.held_by == "r1"                               # still occupied: correct
assert res.sweep(clock) is False                        # sweep is idempotent

Laid out hour by hour, that trace is the answer to the interviewer’s favourite question.

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 are the ones to remember.

At hour 73 — past the original deadline — the sweep still finds nothing to do. That is exactly the behaviour a paid extension has to produce, and it comes for free because the sweep recomputes from the deadline field instead of firing a queued alarm.

At hour 121 the state becomes EXPIRED and lk.held_by is still "r1", because the box has not physically moved. The final line calls the sweep again and gets False, which is idempotency demonstrated rather than asserted.

Every one of those assertions runs in microseconds because the clock is an argument. That is the entire payoff.

The hold window is the capacity knob

The hold length looks like a customer-experience setting. It is really the single number that decides how many packages a bank of a fixed size can serve in a day.

The arithmetic takes five lines. 60 * 24 / hours is “doors times hours in a day, divided by how long each door is tied up”.

lockers in the bank        60
hold window, hours         72
throughput if every hold runs full    60 * 24 / 72   =  20      packages/day
observed mean dwell, hours 14
throughput at mean dwell   60 * 24 / 14   =  102.9              packages/day

Dwell is how long a package actually sits in the locker before someone collects it. In practice it is far shorter than the deadline allows.

So the first calculation asks what the bank could do if every recipient used every hour they were given: 20 packages a day. The second asks what it does when they behave normally: 103.

That is a 5x swing in the number of packages a bank can serve, controlled entirely by a policy constant. It is why “extend the deadline” is a pricing question rather than a user-interface question, and why HOLD_HOURS, MAX_EXTENSIONS and EXTENSION_HOURS belong on a policy object owned by each bank rather than staying module constants.

The sweep costs nothing

Sixty reservations scanned every five minutes is 288 scans a day — 24 hours × 60 minutes ÷ 5 — over a list of sixty objects. That is small enough to sit entirely in the processor’s fastest cache.

Compare that to one scheduled timer per package, which has to be created, persisted, and cancelled every time a deadline moves.


Decision 3 — access codes: scope before length

The deadline is set; the recipient still needs a way through the door. That way is a six-digit code, and how safe it is turns on a modelling decision made long before anyone counts digits.

Six digits, worked out

A 6-digit code feels short. Whether it actually is depends entirely on what it is scoped to — that is, on how many doors a single guessed number could possibly open.

Work it out for the weakest sensible design: one shared keypad, and any live code opens its own door.

code space                 10 ** 6              =  1,000,000
live codes, one bank       60
p(random guess opens a door)  60 / 1,000,000    =  0.00006
expected guesses to a hit  1 / 0.00006          =  16,667
at 3 attempts per hour, hours  16,667 / 3       =  5,556
in days                    5,556 / 24           =  231

Line by line. Six digits give a million possible codes. Sixty live reservations mean sixty of that million are currently valid, so a random guess succeeds with probability 0.00006 — six in a hundred thousand.

The reciprocal of that probability, 16,667, is the expected number of guesses before the first success. Throttling the keypad to three attempts an hour turns those guesses into 5,556 hours, which is 231 days of continuous attack for one expected hit.

Scope beats length

And 231 days is the weak version, with codes scoped to the whole bank.

Scope the code to one door instead — the keypad asks for a locker number first, then a code, and the code is verified against that locker only — and each guess is worth 1 / 1,000,000 rather than 60 / 1,000,000. Sixty times less. Same six digits.

The length of the code is the boring lever. The scope of the code is the interesting one.

Per-door scoping also removes an enumeration oracle: a system that answers a question you were not supposed to be able to ask. With a bank-wide keypad, a valid code tells the attacker not only that the code was right but which door it opens — information the attacker did not have and did not have to work for.

Three properties that live on the object

Three more properties follow, and each one is a field on AccessCode rather than a policy 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

Two terms in that table need unpacking.

A bearer credential is a secret that works for whoever presents it, with no further proof of identity — like a cinema ticket, and unlike a password paired with a username. That is why the stored form matters so much: anyone who reads the database can walk up and open doors.

A salted hash is the standard defence, and it is two ideas stacked:

Here is AccessCode. Four fields, three methods. Read verify closely: the order of its two checks is the point.

from __future__ import annotations

import hashlib
import hmac
import secrets


@dataclass
class AccessCode:
    digest: str
    salt: str
    uses_left: int
    expires_at: datetime

    @staticmethod
    def _hash(code: str, salt: str) -> str:
        return hashlib.sha256((salt + code).encode()).hexdigest()

    @classmethod
    def mint(cls, expires_at: datetime, uses: int = 1) -> tuple[AccessCode, str]:
        code = f"{secrets.randbelow(10 ** 6):06d}"
        salt = secrets.token_hex(8)
        return cls(cls._hash(code, salt), salt, uses, expires_at), code

    def verify(self, attempt: str, clock: Clock) -> bool:
        if self.uses_left <= 0 or clock.now() >= self.expires_at:
            return False
        if not hmac.compare_digest(self.digest, self._hash(attempt, self.salt)):
            return False
        self.uses_left -= 1
        return True

The security decisions hiding in the idioms

Four lines in that block look like Python trivia and are not.

Two more decorators appear there. mint is a class method: it is called on the class rather than on an instance (AccessCode.mint(...)), and cls is the class itself, so cls(...) builds the object. It returns a pair so the plaintext code has exactly one path out of the system — to the recipient — and is never stored.

_hash is a static method: a plain function that lives inside the class for namespacing and takes no self. The leading underscore is Python’s convention for “internal, do not call from outside” — the same convention as _lock later on.

Now exercise it. Four assertions, three claims: a wrong code fails, a right code works exactly once, and an expired code fails even when it is right.

clock = FrozenClock(datetime(2026, 3, 1, 9, 0))
code_obj, plaintext = AccessCode.mint(clock.now() + timedelta(hours=72))
assert code_obj.verify("000000" if plaintext != "000000" else "111111", clock) is False
assert code_obj.verify(plaintext, clock) is True
assert code_obj.verify(plaintext, clock) is False          # single use, consumed

expiring, pt2 = AccessCode.mint(clock.now() + timedelta(hours=1))
clock.advance(2)
assert expiring.verify(pt2, clock) is False                # expiry beats a valid code

The first assertion tries a code guaranteed to be wrong. The conditional expression picks "000000", unless that happens to be the real code, in which case it picks "111111". Either way it is refused.

The correct code then succeeds once and is refused on every attempt after that, because the single use was consumed.

The last pair shows the ordering that matters: a valid code presented after the deadline is still refused, because verify checks expiry before it checks the digest.

Two counters, not one

One subtlety worth saying out loud: uses_left decrements on a correct code, never on an attempt. Notice that the uses_left -= 1 line sits after both early returns.

Rate limiting — capping how many guesses a keypad will accept in an hour, which is where the 231-day figure above came from — is a separate counter, and it belongs on the keypad.

Conflate the two and an attacker can burn a legitimate recipient’s code by guessing at it.


Extension scenarios

Each of the three requirement changes below is the kind an interviewer springs at minute thirty-five. What is being measured is not whether you can code it, but how many files the change touches.

Each one follows the same shape: what changes, what the design did to make that cheap, and what it cost.

“Now support oversized packages that need two adjacent lockers”

What changes: nothing changes in Reservation, because it already holds list[Locker] rather than a single locker. The whole feature is one new policy class.

The policy below looks for two free doors that are physically side by side. Read the if condition — it is three separate requirements joined by and.

class AdjacentPair(AllocationPolicy):
    """Two free lockers in the same bay at consecutive slots, opened as one."""

    def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]:
        by_slot = sorted((lk for lk in lockers if lk.free),
                         key=lambda lk: (lk.bay_id, lk.slot))
        for a, b in zip(by_slot, by_slot[1:]):
            if a.bay_id == b.bay_id and b.slot == a.slot + 1 \
                    and min(a.size, b.size) >= Size.MEDIUM:
                return [a, b]
        return []

Three things in that method need naming.

zip(by_slot, by_slot[1:]) is the standard Python idiom for walking a list in consecutive pairs: it pairs each element with the one after it, so a four-item list yields three pairs.

Sorting by (bay_id, slot) first is what makes those pairs neighbours in the physical cabinet rather than neighbours in an arbitrary list order.

The explicit a.bay_id == b.bay_id check throws away the one bad pair the sort creates — the last locker of one bay sitting next to the first locker of the next, which are on opposite ends of the lobby.

Now the test. Three lockers: two side by side in bay A, one alone in bay B.

bay = [Locker("A0", "A", 0, Size.MEDIUM), Locker("A1", "A", 1, Size.MEDIUM),
       Locker("B0", "B", 0, Size.MEDIUM)]
big = Package("oversized", Size.LARGE)
assert [lk.locker_id for lk in AdjacentPair().select(bay, big)] == ["A0", "A1"]

bay[1].held_by = "someone"                 # break the run
assert AdjacentPair().select(bay, big) == []   # B0 has no neighbour: correct

The second half of that test is the interesting half. Occupying A1 leaves A0 and B0 free. They are adjacent in the list and not adjacent in the lobby, and the policy correctly finds nothing.

The honest limitation: AdjacentPair never calls fits, so it ignores pkg.needs and pkg.size. It is deliberately the narrow policy for “this package is too big for one door”, and a production bank would run it only after the single-door policies came back empty. Say that rather than letting an interviewer find it.

Why the design absorbed this for free

slot was modelled on Locker from the start, and select returned a list rather than an optional single locker. Neither choice cost anything when it was made.

Be honest about the version that does not work. Had select returned Locker | None — the signature nine out of ten candidates write, meaning “a locker, or nothing” — this extension would edit the abstract base class, both existing policies, the caller, and Reservation.lockers.

That is the five-files-for-one-requirement failure, and it is caused by a return type, not by a missing pattern. Return a collection from an allocator whenever “more than one” is imaginable. The cost of the plural signature is that every caller writes chosen[0] in the common case, which is real and much smaller.

Claiming two lockers has to be atomic

Atomic means the whole claim either happens or does not, with no state in between that another caller can observe.

Here is the race. Two couriers arrive with oversized packages. Both can see A0, A1 free. Both call select. Both get the same pair. Both write. One package is now sitting in a door the system believes belongs to the other.

The fix is that the check and the claim happen inside one guarded step, not as two separate operations. That is what LockerBank is for — and it is also where the diagram’s claim and release finally get bodies.

import threading


class LockerBank:
    def __init__(self, lockers: list[Locker], policy: AllocationPolicy):
        self.lockers, self.policy = lockers, policy
        self._lock = threading.Lock()

    def claim(self, res_id: str, pkg: Package) -> list[Locker]:
        with self._lock:                      # select + write under one lock
            chosen = self.policy.select(self.lockers, pkg)
            for lk in chosen:
                lk.held_by = res_id
            return chosen

    def release(self, lockers: list[Locker]) -> None:
        """Give doors back to the pool.

        Called on pick-up or on courier retrieval -- never on expiry, because
        an expired package is still physically inside the door.
        """
        with self._lock:
            for lk in lockers:
                lk.held_by = None

A threading.Lock is a token that only one thread can hold at a time. with self._lock: takes it on entry to the block and releases it on exit, including if an exception is raised.

Selecting outside the lock and writing inside it would be the bug. The check and the claim must not be separable, or a second courier slips between them. This is the classic check-then-act race.

Run it once, and watch the empty list do its job:

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

Read the r10 line again: a full bank answers []. That is the “an empty list is an answer, not an error” contract from the top of the chapter, executing. The last line shows the doors coming back into the pool after release.

The same guarantee across several processes. In a deployment with more than one server there is no shared in-memory lock, so the guarantee comes from a conditional database update instead:

UPDATE lockers SET held_by = ? WHERE locker_id IN (?, ?) AND held_by IS NULL

Then verify that the number of rows changed is 2, and roll back if it is not. The database refuses to update a row somebody else already claimed, which is the same “check and act in one indivisible step” in another medium.

“Now allow a recipient to extend the deadline”

What changes: nothing. The extend method above already does it, and that is the point being made.

The deadline is data on the reservation, and expiry is recomputed from that data on every sweep. An extension is therefore one field write, and the sweep is correct on its next run with no coordination of any kind. The hour-73 assertion in Decision 2 is this feature already passing its test.

What it would have cost under a timer-per-package design: cancel the pending timer, schedule a new one, and handle the case where the cancellation loses the race against the timer firing. At that point the package is returned to sender despite a paid extension, and the customer-facing bug is unreproducible because it depends on millisecond timing.

Prefer recomputed state over scheduled state whenever the schedule can move.

What does change is smaller and worth naming. MAX_EXTENSIONS and EXTENSION_HOURS are module constants in this sketch. They belong on a HoldPolicy object owned by each bank, because a locker bank in a hospital lobby and one in an apartment block do not have the same answer. That is the same Strategy shape as allocation, and it should be introduced only when the second bank actually appears.

“Now add refrigerated lockers”

What changes: the data, and one line of the allocation preference.

The wrong move first

The wrong move is class RefrigeratedLocker(Locker).

Refrigeration is not a kind of locker. It is a capability a locker has. Subclass it and the next capability — charging port, oversized, secure-for-pharmacy — multiplies into RefrigeratedOversizedLocker and then into every other combination.

Capabilities compose; subclasses multiply.

Feasibility is not preference

Locker.features and Package.needs are already sets, so pkg.needs <= lk.features is the whole fit change. fits() does not move at all.

But the allocator is now wrong in a way that fits cannot see. A refrigerated locker fits an ordinary package perfectly well. So SmallestFit hands a scarce chilled door to a paperback, and then rejects the grocery delivery that arrives ten minutes later.

Two different questions have been conflated:

The policy below answers the second question with a three-level sort key.

class ScarcityAware(AllocationPolicy):
    """Feasible, then cheapest: never spend a capability the package did not ask for."""

    def select(self, lockers: list[Locker], pkg: Package) -> list[Locker]:
        usable = [lk for lk in lockers if lk.fits(pkg)]
        if not usable:
            return []
        return [min(usable, key=lambda lk: (len(lk.features - pkg.needs),
                                            lk.size, lk.slot))]

The key function returns a tuple, and Python compares tuples left to right: it decides on the first element and only looks at the second when the first ties. So the three terms are three ranked rules.

  1. len(lk.features - pkg.needs) counts the capabilities this locker has that the package did not ask for. Set subtraction gives the leftover features, so a plain door scores 0 and a chilled door scores 1. The plain door wins.
  2. lk.size breaks ties among equally wasteful doors: smallest fitting door.
  3. lk.slot breaks ties among equal sizes. It exists purely to make the answer deterministic and therefore testable.

Two lockers, two packages, three assertions. The first two compare the old policy against the new one on the same package:

cold = Locker("C1", "A", 0, Size.SMALL, frozenset({"chilled"}))
plain = Locker("P1", "A", 1, Size.SMALL)
book = Package("book", Size.SMALL)
milk = Package("milk", Size.SMALL, frozenset({"chilled"}))

assert SmallestFit().select([cold, plain], book)[0].locker_id == "C1"   # wastes it
assert ScarcityAware().select([cold, plain], book)[0].locker_id == "P1"
assert ScarcityAware().select([cold, plain], milk)[0].locker_id == "C1"

The three assertions are the whole argument:

  1. SmallestFit burns the chilled door on a book, because both doors are SMALL and the chilled one happens to come first on slot order.
  2. ScarcityAware saves the chilled door and gives the book the plain one.
  3. ScarcityAware still hands the chilled door to the milk, which is what the door is for.

What this cost: the sort key is now three levels deep, and the reason for each term lives in prose beside the code rather than in a name inside it.

That is the honest price of a preference function, and it is why “add a new locker feature” is a data change while “add a new reason to prefer a locker” is a code change. A design that made both free would be a rules engine — a general system for evaluating configurable rules — and a rules engine in an object-oriented design interview is a candidate solving a problem nobody asked about.


What interviewers probe

These are the questions this design exists to answer. The right-hand column is what to say — not a summary of it, the actual answer.

ProbeThe answer that lands
“What happens at hour 73?”a sweep flips AWAITING_PICKUP -> EXPIRED; the locker stays held because the box is still in it; a courier route picks it up and only then is the door freed
“How do you test that?”inject the clock, advance it 73 hours, assert. If the answer involves sleeping or patching the system clock, the design already lost
“Two couriers, one locker”select-and-claim under one lock, or a conditional update with a row-count check — never check-then-write
“Is 6 digits enough?”scope first: per-door verification makes each guess worth 1 / 1,000,000; then rate limit; then length
“Reuse the same code for a second package?”only with uses_left > 1 minted deliberately; the default is single-use, and PICKED_UP is terminal
“Where does Singleton go?”nowhere. A Singleton is a class rigged so only one instance can exist; LockerBank looks like one until the second bank ships, and a global clock is exactly the thing that makes expiry untestable. Both are constructor arguments — see ch 03
“Why not model real dimensions?”the doors are discrete; quantizing turns three-dimensional packing into an ordinal comparison. Offer the packing version if they want it

Cheat sheet

One line per idea, in the order you would draw them. If you can restate the right-hand column from the left-hand label alone, you can redraw the model.

Aggregate 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 is wrong, SmallestFit is the default, AdjacentPair and ScarcityAware are the extensions
State machineRESERVED -> AWAITING_PICKUP -> PICKED_UP / -> EXPIRED -> RETURNED_TO_SENDER
The non-obvious ruleEXPIRED does not free the locker; retrieval does
Clockinjected Protocol; the sweep is a function of the reservations and the time, never a timer per package
Capacity60 * 24 / 72 = 20 packages/day at full holds, 102.9 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 and check the row count
Say out loud“Reserved-but-empty and occupied are different states with different timeouts”
Trapmodelling refrigeration as a subclass; capabilities compose, subclasses multiply

Two links, offered for depth and not needed to follow anything above. The interview method — how to spend the 45 minutes — is ch 02, and the pattern vocabulary is ch 03.

The next chapter takes the same state-machine discipline into a system where a wrong transition costs real money: ch 13 — ATM.