Design Amazon Locker (object-oriented)

0
⑂ RemixPractice

Model a locker location with compartments of several sizes: assign a package to a compartment that fits, issue a pickup code, and handle expiry and returns.

AmazonCodingMedium

What L5 looks like

  • An explicit parcel lifecycle that covers expiry and both kinds of return, and explains why an expired compartment stays locked until a carrier collects.
  • Atomic slot allocation under concurrent deposits, random one-time codes from secrets, and an injected clock so expiry tests don't sleep.
  • Uses a strategy for allocation and events for notifications, without over-engineering the state machine.
Interviewer expectations for every level ↓

Requirements

  • One locker location has a fixed set of compartments, each SMALL, MEDIUM or LARGE.
  • Deliver: a carrier deposits a package. The system picks the smallest free compartment the package fits in, opens its door, and issues a one-time pickup code to the customer.
  • Pick up: the customer types the code at the kiosk. If the code is valid and not expired, the compartment opens and the code stops working.
  • Expiry: a package not picked up within the hold window (3 days, configurable) expires. Its code stops working, the compartment stays locked, and the package waits for a carrier to take it back to the sender.
  • Customer returns: a customer reserves a compartment for an item they are sending back, gets a drop-off code, drops the item off, and a carrier collects it later. If nobody uses a reservation, it expires and frees the compartment.
  • Two concurrent deposits must never get the same compartment, and a code maps to exactly one live parcel. A compartment taken out of service (broken door) is never assigned.
  • Out of scope: orders, sending notifications (we only emit events), payments, carrier authentication, choosing which location, door hardware.

Core objects

Object Responsibility
Size, SlotState (enums) Ordered size, so "fits" means slot.size >= pkg.size; compartment state FREE, OCCUPIED, OUT_OF_SERVICE.
Compartment One physical door: id, size, state. open_door() calls the hardware.
Package The item that goes in: id and size.
ParcelState (enum) Lifecycle of one assignment, for a delivery or a return.
Assignment Binds a package to a compartment, with a code, an expiry and a state.
FitStrategy (ABC) Chooses a compartment for a size. SmallestFit is the default.
LockerLocation Facade for deposits, kiosk codes, return reservations, carrier collection and the expiry sweep. Owns the lock and the indexes; raises LockerError.

Class design

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"loc","x":0,"y":220,"width":310,"height":180,"label":{"text":"LockerLocation\ncompartments\nby_code, by_package\ndeposit(), reserve_return()\nenter_code(), collect()\nsweep_expired()"}},
 {"type":"rectangle","id":"asg","x":520,"y":240,"width":240,"height":130,"label":{"text":"Assignment\npackage, compartment\ncode, expires_at\nstate"}},
 {"type":"rectangle","id":"comp","x":960,"y":60,"width":200,"height":110,"label":{"text":"Compartment\nid, size, state\nopen_door()"}},
 {"type":"rectangle","id":"pkg","x":960,"y":390,"width":180,"height":90,"label":{"text":"Package\nid, size"}},
 {"type":"rectangle","id":"fit","x":45,"y":520,"width":220,"height":110,"label":{"text":"FitStrategy\n(abstract)\nchoose(size, free)"}},
 {"type":"rectangle","id":"small","x":440,"y":540,"width":180,"height":70,"label":{"text":"SmallestFit"}},
 {"type":"arrow","id":"loc-asg","x":310,"y":310,"width":210,"height":-10,"start":{"id":"loc"},"end":{"id":"asg"},"label":{"text":"has many"}},
 {"type":"arrow","id":"loc-comp","x":155,"y":220,"width":805,"height":-105,"start":{"id":"loc"},"end":{"id":"comp"},"points":[[0,0],[0,-105],[805,-105]],"label":{"text":"has many"}},
 {"type":"arrow","id":"asg-comp","x":760,"y":265,"width":200,"height":-125,"start":{"id":"asg"},"end":{"id":"comp"},"label":{"text":"has"}},
 {"type":"arrow","id":"asg-pkg","x":760,"y":345,"width":200,"height":90,"start":{"id":"asg"},"end":{"id":"pkg"},"label":{"text":"has"}},
 {"type":"arrow","id":"loc-fit","x":155,"y":400,"width":1,"height":120,"start":{"id":"loc"},"end":{"id":"fit"},"label":{"text":"uses"}},
 {"type":"arrow","id":"small-fit","x":440,"y":575,"width":-175,"height":0,"start":{"id":"small"},"end":{"id":"fit"},"label":{"text":"extends"}}
]}

Key flows

1. Deposit (carrier). deposit(pkg) takes the lock and filters the FREE compartments. FitStrategy.choose picks one, which is marked OCCUPIED. A unique 6-digit code is generated, and an Assignment(READY) with expires_at = now + hold is indexed by code and by package id. After the lock is released, the door opens and a ready event fires; the notifier sends the code to the customer.

2. Kiosk code (customer). enter_code(code) looks up the assignment in the live-code index (door I/O and events always run after the lock is released). It must be READY or AWAITING_DROPOFF, and now < expires_at. That time check matters because the sweep may not have run yet. The code is removed, since it works only once. A READY parcel becomes PICKED_UP and frees its compartment. A return becomes DROPPED_OFF and keeps the compartment for the carrier. The door opens.

3. Expiry sweep (scheduler, every few minutes). sweep_expired() moves each overdue READY parcel to EXPIRED and removes its code. The compartment stays OCCUPIED, and a return_to_sender event goes to the carriers. An unused return reservation that is overdue becomes CANCELLED and frees its compartment.

4. Carrier collection. collect(package_id) works for EXPIRED and DROPPED_OFF parcels. It opens the door, marks the parcel COLLECTED and frees the compartment. A return starts with reserve_return(pkg), which assigns a compartment like a deposit but in state AWAITING_DROPOFF, without opening the door.

{"type":"excalidraw","elements":[
 {"type":"ellipse","id":"start","x":0,"y":215,"width":100,"height":60,"label":{"text":"start"}},
 {"type":"rectangle","id":"ready","x":300,"y":100,"width":280,"height":70,"label":{"text":"READY"}},
 {"type":"rectangle","id":"await","x":300,"y":380,"width":280,"height":70,"label":{"text":"AWAITING_DROPOFF"}},
 {"type":"rectangle","id":"picked","x":840,"y":-60,"width":200,"height":70,"label":{"text":"PICKED_UP"}},
 {"type":"rectangle","id":"expired","x":840,"y":100,"width":200,"height":70,"label":{"text":"EXPIRED"}},
 {"type":"rectangle","id":"dropped","x":840,"y":380,"width":200,"height":70,"label":{"text":"DROPPED_OFF"}},
 {"type":"rectangle","id":"cancelled","x":840,"y":540,"width":200,"height":70,"label":{"text":"CANCELLED"}},
 {"type":"rectangle","id":"collected","x":1300,"y":240,"width":200,"height":70,"label":{"text":"COLLECTED"}},
 {"type":"arrow","id":"deposit","x":100,"y":230,"width":200,"height":-95,"start":{"id":"start"},"end":{"id":"ready"},"label":{"text":"deposit"}},
 {"type":"arrow","id":"reserve","x":100,"y":260,"width":200,"height":155,"start":{"id":"start"},"end":{"id":"await"},"label":{"text":"reserve_return"}},
 {"type":"arrow","id":"r-pick","x":580,"y":115,"width":260,"height":-140,"start":{"id":"ready"},"end":{"id":"picked"},"label":{"text":"enter_code"}},
 {"type":"arrow","id":"r-exp","x":580,"y":145,"width":260,"height":0,"start":{"id":"ready"},"end":{"id":"expired"},"label":{"text":"sweep, hold passed"}},
 {"type":"arrow","id":"e-col","x":1040,"y":135,"width":260,"height":125,"start":{"id":"expired"},"end":{"id":"collected"},"label":{"text":"carrier collect"}},
 {"type":"arrow","id":"a-drop","x":580,"y":405,"width":260,"height":0,"start":{"id":"await"},"end":{"id":"dropped"},"label":{"text":"enter_code"}},
 {"type":"arrow","id":"a-cancel","x":580,"y":435,"width":260,"height":140,"start":{"id":"await"},"end":{"id":"cancelled"},"label":{"text":"sweep, unused"}},
 {"type":"arrow","id":"d-col","x":1040,"y":415,"width":260,"height":-125,"start":{"id":"dropped"},"end":{"id":"collected"},"label":{"text":"carrier collect"}}
]}

Code

import secrets, threading
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum, IntEnum
from typing import Callable, Optional

Size = IntEnum("Size", "SMALL MEDIUM LARGE")  # ordered: a slot fits if slot.size >= pkg.size
SlotState = Enum("SlotState", "FREE OCCUPIED OUT_OF_SERVICE")
ParcelState = Enum("ParcelState",
                   "READY PICKED_UP EXPIRED AWAITING_DROPOFF DROPPED_OFF COLLECTED CANCELLED")

class LockerError(Exception): ...

@dataclass
class Compartment:
    id: str
    size: Size
    state: SlotState = SlotState.FREE
    def open_door(self) -> None: print(f"open {self.id}")  # hardware call in a real kiosk

@dataclass(frozen=True)
class Package:
    id: str
    size: Size

@dataclass
class Assignment:
    package: Package
    compartment: Compartment
    code: str
    expires_at: datetime
    state: ParcelState

class FitStrategy(ABC):
    @abstractmethod
    def choose(self, size: Size, free: list[Compartment]) -> Optional[Compartment]: ...

class SmallestFit(FitStrategy):
    def choose(self, size: Size, free: list[Compartment]) -> Optional[Compartment]:
        fits = [c for c in free if c.size >= size]
        return min(fits, key=lambda c: c.size, default=None)

class LockerLocation:
    def __init__(self, compartments: list[Compartment], strategy: FitStrategy = SmallestFit(),
                 hold: timedelta = timedelta(days=3), clock: Callable[[], datetime] = datetime.now,
                 on_event: Callable[[str, Assignment], None] = lambda e, a: None) -> None:
        self._compartments = compartments
        self._strategy, self._hold, self._clock, self._emit = strategy, hold, clock, on_event
        self._lock = threading.Lock()
        self._by_code: dict[str, Assignment] = {}     # live, unused codes only
        self._by_package: dict[str, Assignment] = {}  # every assignment holding a slot

    def deposit(self, package: Package) -> Assignment:
        return self._assign(package, ParcelState.READY, open_now=True)

    def reserve_return(self, package: Package) -> Assignment:
        return self._assign(package, ParcelState.AWAITING_DROPOFF, open_now=False)

    def enter_code(self, code: str) -> Assignment:
        with self._lock:
            a = self._by_code.get(code)
            if a is None or self._clock() >= a.expires_at:
                raise LockerError("invalid or expired code")
            del self._by_code[code]  # one-time code
            if a.state is ParcelState.READY:
                a.state = ParcelState.PICKED_UP
                self._release(a)
            else:
                a.state = ParcelState.DROPPED_OFF  # slot stays held for the carrier
            state = a.state
        a.compartment.open_door()
        self._emit(state.name.lower(), a)
        return a

    def collect(self, package_id: str) -> Package:
        with self._lock:
            a = self._by_package.get(package_id)
            if a is None or a.state not in (ParcelState.EXPIRED, ParcelState.DROPPED_OFF):
                raise LockerError("nothing to collect")
            a.state = ParcelState.COLLECTED
            self._release(a)
        a.compartment.open_door()
        self._emit("collected", a)
        return a.package

    def sweep_expired(self) -> list[Assignment]:
        changed: list[Assignment] = []
        with self._lock:
            for a in list(self._by_package.values()):
                if self._clock() < a.expires_at or a.code not in self._by_code:
                    continue  # not due, or code already used
                if a.state is ParcelState.READY:
                    a.state = ParcelState.EXPIRED  # stays locked until a carrier collects
                    del self._by_code[a.code]
                else:
                    a.state = ParcelState.CANCELLED
                    self._release(a)
                changed.append(a)
        for a in changed:
            self._emit("return_to_sender" if a.state is ParcelState.EXPIRED else "cancelled", a)
        return changed

    def _assign(self, package: Package, state: ParcelState, open_now: bool) -> Assignment:
        with self._lock:
            if package.id in self._by_package:
                raise LockerError(f"package {package.id} already assigned")
            free = [c for c in self._compartments if c.state is SlotState.FREE]
            slot = self._strategy.choose(package.size, free)
            if slot is None:
                raise LockerError("no free compartment fits this package")
            slot.state = SlotState.OCCUPIED
            a = Assignment(package, slot, self._new_code(), self._clock() + self._hold, state)
            self._by_code[a.code] = self._by_package[package.id] = a
        if open_now:
            slot.open_door()
        self._emit(state.name.lower(), a)
        return a

    def _release(self, a: Assignment) -> None:  # caller holds the lock
        if a.compartment.state is SlotState.OCCUPIED:  # never revive OUT_OF_SERVICE
            a.compartment.state = SlotState.FREE
        self._by_code.pop(a.code, None)
        self._by_package.pop(a.package.id, None)

    def _new_code(self) -> str:  # caller holds the lock
        while True:
            code = f"{secrets.randbelow(10**6):06d}"
            if code not in self._by_code:
                return code

if __name__ == "__main__":
    loc = LockerLocation([Compartment("S1", Size.SMALL), Compartment("M1", Size.MEDIUM),
                          Compartment("L1", Size.LARGE)])
    a, b = loc.deposit(Package("p1", Size.MEDIUM)), loc.deposit(Package("p2", Size.MEDIUM))
    assert (a.compartment.id, b.compartment.id) == ("M1", "L1")  # smallest fit, then next
    assert loc.enter_code(a.code).state is ParcelState.PICKED_UP  # M1 is free again
    r = loc.reserve_return(Package("ret1", Size.SMALL))
    loc.enter_code(r.code)                        # customer drops the item in S1
    assert loc.collect("ret1").id == "ret1"

Design patterns

  • Strategy: FitStrategy keeps "which compartment" out of the locker. SmallestFit keeps large doors free for large parcels. Other rules, such as spreading wear across doors or keeping some LARGE free, plug in without changing LockerLocation.
  • Facade: the kiosk, the carrier app and the scheduler all call LockerLocation. Nothing else changes compartment state, so the invariants live in one place.
  • State (as an enum state machine): each operation checks the ParcelState first, for example collect needs EXPIRED or DROPPED_OFF. With this few transitions, an enum check is clearer than one class per state.
  • Observer: the on_event callback separates SMS and email, carrier dispatch and metrics from the core flows.

Extensions

  • Many locations, "find me a locker": a LockerNetwork holds LockerLocations and asks the ones near the customer for a fit. Reserve the compartment when the order ships (a new AWAITING_DELIVERY state with its own expiry), not when the carrier arrives. Each location has its own lock, so they scale independently.
  • Persistence, several kiosks per site: store compartments and assignments in a database. Replace the in-process lock with a conditional update (UPDATE compartment SET state='OCCUPIED' WHERE id=? AND state='FREE') and a unique index on live codes.
  • Hold extensions and per-item windows: add extend(code, days), which moves expires_at while the parcel is READY. Turn the fixed hold into a HoldPolicy.window(package), for example a short window for refrigerated goods.

Interviewer expectations

Mid-level (L4)

  • Clean classes for compartment, package, size and locker, with a working "smallest compartment that fits" rule.
  • Generates and checks the pickup code, frees the compartment after pickup, and handles no fit, a wrong code and a reused code.

Senior (L5)

  • An explicit parcel lifecycle that covers expiry and both kinds of return, and explains why an expired compartment stays locked until a carrier collects.
  • Atomic slot allocation under concurrent deposits, random one-time codes from secrets, and an injected clock so expiry tests don't sleep.
  • Uses a strategy for allocation and events for notifications, without over-engineering the state machine.

Staff (L6+)

  • Takes the design to a database: conditional updates instead of locks, idempotent carrier calls, and recovery when the door fails to open after a commit.
  • Reasons about capacity: reserving at ship time, overbooking risk, the size mix per location, and a backlog when sweeps fall behind.
  • Covers security and operations: brute-force limits on codes, an audit trail per door, and handling a broken compartment with a package inside.

Make it yours

Write each chapter yourself, check it against this answer, then let the coach grill you.