Amazon Locker
Self-service package pickup with OTP codes and dimensional matching. A locker is a parking-spot-for-boxes: find-smallest-that-fits, reserve, unlock with a code.
Key Abstractions
One slot in a station. Size, state, current reservation.
Bank of lockers at one location. Routes incoming packages to the right size.
Dimensions, weight, recipient, and delivery deadline
Links a package to a locker, generates an OTP, tracks expiry
Picks the best locker — smallest-that-fits is the default
Generates, verifies, and invalidates one-time pickup codes
Class Diagram
The Key Insight
The design looks like a parking lot variant and it mostly is — slots, routing, reservations. The key difference is the OTP flow. A parking lot's "ticket" can be stored as-is; a locker code is what lets a stranger unlock a specific locker, so it must be unguessable, hashed in storage, and verified in constant time. Leak the hash and you leak nothing; leak plaintext codes and half the station is compromised.
Smallest-that-fits is not just efficiency — it's capacity strategy. If every small package goes into a large locker, the moment a large package arrives there's nowhere to put it. A station with thousands of drops per day will fail daily under a naive policy. The strategy pattern captures this directly: the default picks smallest-that-fits, but a holiday variant might prefer spreading load.
Expiry is a real feature, not a cleanup chore. Packages that sit for three days past their deadline must be reclaimed — both for capacity and to return them to Amazon. Modeling expiry explicitly (lazy check on pickup + periodic sweep) surfaces the "returned to sender" workflow cleanly.
Requirements
Functional
- Match a package to the smallest locker that fits
- Generate a one-time pickup code delivered to the recipient
- Verify the code at pickup; unlock the locker; mark it available
- Expire unpicked packages after the hold window
- Track locker availability by size
Non-Functional
- OTPs stored hashed; verification is constant-time
- Allocation is O(lockers) per drop-off
- Thread-safe concurrent drop-offs and pickups
- Failure on "no fit" must be immediate and clear
Design Decisions
Why smallest-that-fits?
Larger lockers are scarce. Reserving one for a shoebox means the next fridge-sized package has nowhere to go. Smallest-that-fits keeps the inventory pyramid intact — small packages use small slots, large packages use large slots.
Why OTP hashed?
Breach hygiene. If the database is compromised, the attacker has hashes — useless without brute force. With 6-digit codes and per-OTP salt, even that's infeasible within the short TTL. Plaintext storage fails every compliance audit.
Why rotate dimensions in fit check?
A 30x10x5 package fits a 10x30x5 locker if oriented correctly. Sorting dimensions makes the comparison orientation-independent. Without this, half of plausible drop-offs get rejected for no real reason.
Why unified "invalid" error on pickup?
Different errors ("expired," "wrong code," "unknown reservation") let an attacker probe whether a reservation ID exists. Returning the same error for all three denies information; defensible security posture.
Why a Reservation separate from Locker.reservation_id?
The reservation carries state that the locker doesn't care about — OTP hash, expiry, status history. Co-locating them would bloat the Locker class and mix concerns. The indirection (reservation_id pointer) is cheap and keeps responsibilities clean.
Interview Follow-ups
- "What if a locker malfunctions?"
OUT_OF_SERVICEstate; allocation strategy skips it. A tech visit clears the flag. - "How do you handle refrigerated lockers?" Extend
LockerSizewith aCategory(ambient, refrigerated, frozen). Packages carry the same category requirement; strategy filters on category first. - "What about package photos at pickup?" Camera module takes a photo on unlock, attached to the reservation. Dispute resolution uses it.
- "How would you support returns (customer drops a package for pickup by Amazon)?" Same flow in reverse — customer scans barcode, station allocates locker, generates a pickup code for Amazon's carrier. Same data model, different state transitions.
- "How do you scale to millions of lockers across cities?" Each station is independent. A
StationRegistryaggregates for customer-facing "nearest locker with room for my package" queries. Sharded by geo.
45-Minute LLD Playbook
Each phase is what the interviewer expects you to do and say. Concrete steps, not topic hints. Diagrams are what you would sketch on the board.
- 15 min
Clarify scope and lock the process
GoalPin the OTP-vs-ticket distinction, smallest-that-fits as the default allocation, and the expiry sweep before any code.
Do & Say- ASK·1Open with: The OTP flow is what makes this different from a parking lot. A stranger has to open one specific locker with a number we hand out. The code must be unguessable, hashed in storage, and verified in constant time. Are we aligned?
- SAY·2Lock the flows in scope: driver drops off, recipient picks up with OTP, expired packages get reclaimed by a sweep. Park refrigerated lockers, returns flow, multi-station nearest-locker search as v2.
- SAY·3Pin the allocation policy: Default is smallest-that-fits. If every shoebox lands in a fridge-sized locker, the next fridge-sized package has nowhere to go and the station fails at peak load.
- SAY·4Pin the rotation-aware fit check: A 30x10x5 package fits a 10x30x5 locker if rotated. I'll sort both dimension tuples and compare coordinate-wise.
- SAY·5State the unified-error rule: Pickup returns the same error for expired, wrong code, and unknown reservation. Different errors let an attacker probe whether a reservation exists.
- ASK·6Ask the process question: I'd like to sketch a quick class diagram for Locker, Reservation, Strategy, then code. Five minutes on the diagram or three on signatures, your call.
Interviewer is grading: You raise hashing, constant-time compare, and the unified-error rule in the first two minutes. You park returns and refrigerated lockers explicitly. You name smallest-that-fits as a capacity strategy, not just an efficiency hint.
- 25-10 min
Sketch the API and (optionally) the class diagram
GoalLock the public method signatures and name Strategy at allocation, State at Locker/Reservation, and the OTP factory.
Do & Say- SAY·1Name the abstractions: Dimensions (with rotation-aware fits_inside), LockerSize enum (SMALL/MEDIUM/LARGE/XL with dimensions), Locker (id, size, dims, state, reservation_id), LockerState enum, Package, OtpService, Reservation, AllocationStrategy with SmallestThatFits as default, and LockerStation.
- WRITE·2Write Dimensions.fits_inside: sort both tuples descending, compare coordinate-wise. That's the rotation-aware check.
- WRITE·3Write OtpService.generate: returns (plaintext_code, hash). Plaintext is shown once to recipient, never stored. Hash is stored on the Reservation. verify(code, stored_hash) does a constant-time compare. Say: Per-OTP salt in production, fixed salt here for brevity.
- WRITE·4Write LockerStation.drop_off: drop_off(package, hold_ttl=3 days) -> (Reservation, otp). Picks a locker via the strategy, generates OTP, sets locker state to OCCUPIED, returns the reservation and the plaintext OTP exactly once. Say: expires_at is the min of package.deadline and now + hold_ttl, so a tight deadline caps the hold window.
- WRITE·5Write LockerStation.pickup: pickup(reservation_id, otp) -> Package. Failure rule: same error for unknown, expired, or wrong code. Success: frees the locker, marks reservation COMPLETED, and returns the package.
- WRITE·6Write sweep_expired: iterates active reservations, expires any past their expires_at, frees the locker, transitions reservation to EXPIRED. Returns count for monitoring.
- SAY·7If diagram was requested, draw it now. Layout: LockerStation at top, holding Lockers and an AllocationStrategy, creating Reservations, Reservation pointing at Package and Locker, and OtpService as a free-floating utility. Annotate: Strategy at AllocationStrategy and State at LockerState and ReservationStatus.
Interviewer is grading: OTP is hashed and verified in constant time, said out loud before code starts. Strategy is at AllocationStrategy, State is at LockerState plus ReservationStatus. Expiry is the min of package deadline and TTL, not just TTL.
- 325 min
Code in this sequence (bottom-up)
GoalType the code in the same order the existing implementation builds it: enums and value types, OtpService, Strategy, then LockerStation. Talk while you code.
Do & Say- SAY·1Start with enums and value types: LockerSize enum-with-data (SMALL=(30,30,10), MEDIUM, LARGE, XL), LockerState, and ReservationStatus. Then: Dimensions frozen dataclass with rotation-aware fits_inside and a volume property and Package dataclass with id, recipient, dims, weight_grams, deadline. Say: Volume is the tiebreak for smallest-that-fits. (~5 min)
- SAY·2Code Locker. Plain class with fields: id, size, dims (built from size.value), state defaulting to AVAILABLE, and reservation_id None. is_available property checks state == AVAILABLE. Say: reservation_id is a back-pointer for the sweep, not the source of truth. (~2 min)
- SAY·3Code OtpService as a static class. generate returns (code, hash) where code is a 6-digit secrets.randbelow string and hash is sha256 of locker-salt- + code. verify uses secrets.compare_digest for constant-time compare. Say: compare_digest defends against timing attacks. A naive string equality leaks how many characters match. (~3 min)
- SAY·4Code Reservation dataclass with id, package_id, locker_id, otp_hash, expires_at, status defaulting to ACTIVE. (~1 min)
- SAY·5Code AllocationStrategy abstract base with pick(lockers, package) -> Locker | None. Then SmallestThatFits steps: filter to available lockers where package.dims.fits_inside(locker.dims), return min by locker.dims.volume, and return None on empty. Say: min by volume preserves bigger lockers for bigger packages. (~3 min)
- SAY·6Code LockerStation. Constructor takes station_id, locker list, optional strategy (default SmallestThatFits), builds the lockers dict, reservations dict, packages dict, an RLock. (~2 min)
- SAY·7Code drop_off under the lock. Steps: check package.deadline > now, call strategy.pick (raise if None), generate OTP, set expires_at = min(package.deadline, now + hold_ttl), build Reservation, flip locker to OCCUPIED, store, and return (reservation, plaintext_otp). Say: plaintext OTP returned once, never stored on the station. (~4 min)
- SAY·8Code pickup. Under the lock: fetch reservation, raise the unified Invalid reservation or code if missing, not ACTIVE, expired, or OTP mismatch. On success, free the locker, set reservation COMPLETED, return the package. Say: Same error for all four failure modes. Don't leak existence. (~3 min)
- SAY·9Code sweep_expired and the private _expire_reservation helper. Behaviors: sweep iterates active reservations, calls _expire_reservation for any past expires_at and helper sets status EXPIRED, frees the locker. Say: Lazy: pickup expires on the fly, sweep is the background backstop for no-shows. (~2 min)
- SAY·10Mental walk-through. Happy path: Driver drops a 20x20x5 shoebox, SmallestThatFits picks SMALL (MEDIUM also fits but larger volume), OTP hashed, stored, and recipient gets plaintext, pickup verifies, locker freed. No-show: sweep_expired flips to EXPIRED after 3 days and frees the locker. Done. (~1 min)
Interviewer is grading: fits_inside sorts dimensions, doesn't compare coordinate-by-coordinate raw. OtpService.verify uses compare_digest. Pickup returns the same error for all failure modes. expires_at is min of package deadline and TTL, not just TTL.
- 45 min
Trade-offs, extensions, and wrap-up
GoalDefend smallest-that-fits over largest-first, defend separate Reservation over locker-embedded state, volunteer returns flow, anticipate refrigerated lockers, close in one sentence.
Do & Say- SAY·1Trade-off one, smallest-that-fits over first-fit or largest-first: Largest-first is the worst possible policy. Every shoebox eats an XL and the station fails by noon. First-fit isn't bad but it's order-dependent. Smallest-that-fits preserves the inventory pyramid: small packages use small slots, large packages use large slots.
- SAY·2Trade-off two, separate Reservation over storing OTP and expiry on Locker: Reservation owns OTP hash, expires_at, and status history and Locker owns physical state and a back-pointer. Cost of co-locating: bloats Locker and forces schema migration every time we add a field like photo_url for disputes.
- SAY·3Extension to volunteer, returns flow: Customer drops a package for Amazon's carrier to pick up. Same data model, flipped roles: customer is the depositor, carrier is the pickup-er. drop_off becomes drop_for_pickup, the OTP goes to the carrier's app. The state machine handles this cleanly because the transitions are symmetric.
- WATCH·4Anticipated follow-up: How do refrigerated lockers fit? Answer: Extend LockerSize (or split into Size and Category enums: ambient, refrigerated, frozen). Package carries a required Category. SmallestThatFits filters on Category first, then volume. The strategy abstraction means I don't touch LockerStation at all.
- SAY·5Close in one sentence covering each pillar: SmallestThatFits as the default allocation strategy, OTP hashed with constant-time verify, expires_at as min of package deadline and TTL, unified error on pickup, and a sweep for no-show reclaim.
Interviewer is grading: You defend smallest-that-fits with the inventory-pyramid argument. You volunteer the returns flow unprompted. You can explain refrigerated lockers in one sentence using the Strategy hook.
Code Implementation
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from threading import RLock
import hashlib
import secrets
import uuid
class LockerSize(Enum):
SMALL = (30, 30, 10)
MEDIUM = (45, 45, 30)
LARGE = (60, 60, 45)
XL = (90, 90, 60)
def __init__(self, length: int, width: int, height: int):
self.length = length
self.width = width
self.height = height
class LockerState(Enum):
AVAILABLE = "available"
RESERVED = "reserved"
OCCUPIED = "occupied"
OUT_OF_SERVICE = "out_of_service"
class ReservationStatus(Enum):
ACTIVE = "active"
COMPLETED = "completed"
EXPIRED = "expired"
@dataclass(frozen=True)
class Dimensions:
length: int
width: int
height: int
def fits_inside(self, other: "Dimensions") -> bool:
# Allow rotation: sort both sorted descending and compare coordinate-wise.
a = sorted([self.length, self.width, self.height], reverse=True)
b = sorted([other.length, other.width, other.height], reverse=True)
return all(x <= y for x, y in zip(a, b))
@property
def volume(self) -> int:
return self.length * self.width * self.height
@dataclass
class Package:
id: str
recipient: str
dims: Dimensions
weight_grams: int
deadline: datetime
class Locker:
def __init__(self, locker_id: str, size: LockerSize):
self.id = locker_id
self.size = size
self.dims = Dimensions(*size.value)
self.state = LockerState.AVAILABLE
self.reservation_id: str | None = None
@property
def is_available(self) -> bool:
return self.state == LockerState.AVAILABLE
class OtpService:
"""OTPs stored hashed; verification uses constant-time compare."""
@staticmethod
def generate() -> tuple[str, str]:
# 6-digit code, returned alongside its hash. Station stores only the hash.
code = f"{secrets.randbelow(1_000_000):06d}"
return code, OtpService._hash(code)
@staticmethod
def verify(code: str, stored_hash: str) -> bool:
return secrets.compare_digest(OtpService._hash(code), stored_hash)
@staticmethod
def _hash(code: str) -> str:
# SHA-256 with a fixed salt here for brevity; production wants per-OTP salt or argon2.
return hashlib.sha256(("locker-salt-" + code).encode()).hexdigest()
@dataclass
class Reservation:
id: str
package_id: str
locker_id: str
otp_hash: str
expires_at: datetime
status: ReservationStatus = ReservationStatus.ACTIVE
class AllocationStrategy(ABC):
@abstractmethod
def pick(self, lockers: list[Locker], package: Package) -> Locker | None: ...
class SmallestThatFits(AllocationStrategy):
"""Preserve larger lockers for larger packages."""
def pick(self, lockers: list[Locker], package: Package) -> Locker | None:
candidates = [
l for l in lockers
if l.is_available and package.dims.fits_inside(l.dims)
]
if not candidates:
return None
return min(candidates, key=lambda l: l.dims.volume)
class LockerStation:
def __init__(self, station_id: str, lockers: list[Locker],
strategy: AllocationStrategy | None = None):
if not lockers:
raise ValueError("station must have at least one locker")
self.id = station_id
self._lockers = {l.id: l for l in lockers}
self._strategy = strategy or SmallestThatFits()
self._reservations: dict[str, Reservation] = {}
self._packages: dict[str, Package] = {}
self._lock = RLock()
def drop_off(self, package: Package, hold_ttl: timedelta = timedelta(days=3)) -> tuple[Reservation, str]:
"""Driver drops a package. Returns the reservation and the OTP (shown once to recipient).
The reservation expires at the earlier of:
- package.deadline (absolute cutoff the carrier committed to the sender)
- now + hold_ttl (default station hold window)
"""
with self._lock:
now = datetime.utcnow()
if package.deadline <= now:
raise RuntimeError("Package deadline is already in the past")
locker = self._strategy.pick(list(self._lockers.values()), package)
if locker is None:
raise RuntimeError("No locker fits this package at this station")
otp, otp_hash = OtpService.generate()
expires_at = min(package.deadline, now + hold_ttl)
reservation = Reservation(
id=str(uuid.uuid4())[:8],
package_id=package.id,
locker_id=locker.id,
otp_hash=otp_hash,
expires_at=expires_at,
)
locker.state = LockerState.OCCUPIED
locker.reservation_id = reservation.id
self._reservations[reservation.id] = reservation
self._packages[package.id] = package
return reservation, otp
def pickup(self, reservation_id: str, otp: str) -> Package:
with self._lock:
reservation = self._reservations.get(reservation_id)
# Unified error — don't leak existence.
if reservation is None or reservation.status != ReservationStatus.ACTIVE:
raise PermissionError("Invalid reservation or code")
if datetime.utcnow() >= reservation.expires_at:
self._expire_reservation(reservation)
raise PermissionError("Invalid reservation or code")
if not OtpService.verify(otp, reservation.otp_hash):
raise PermissionError("Invalid reservation or code")
locker = self._lockers[reservation.locker_id]
pkg = self._packages.pop(reservation.package_id)
reservation.status = ReservationStatus.COMPLETED
locker.state = LockerState.AVAILABLE
locker.reservation_id = None
return pkg
def sweep_expired(self) -> int:
"""Background hygiene — reclaim lockers whose pickup deadlines have passed."""
with self._lock:
now = datetime.utcnow()
expired = [r for r in self._reservations.values()
if r.status == ReservationStatus.ACTIVE and now >= r.expires_at]
for r in expired:
self._expire_reservation(r)
return len(expired)
def _expire_reservation(self, r: Reservation) -> None:
r.status = ReservationStatus.EXPIRED
locker = self._lockers[r.locker_id]
locker.state = LockerState.AVAILABLE
locker.reservation_id = None
# In production this triggers a "return to sender" workflow.
def availability_by_size(self) -> dict[LockerSize, int]:
with self._lock:
counts: dict[LockerSize, int] = {}
for locker in self._lockers.values():
if locker.is_available:
counts[locker.size] = counts.get(locker.size, 0) + 1
return counts
if __name__ == "__main__":
# Station with 2 lockers per size.
lockers = []
for i, size in enumerate(LockerSize):
for j in range(2):
lockers.append(Locker(f"L-{size.name}-{j}", size))
station = LockerStation("AMZL-001", lockers)
print("Initial availability:", {s.name: c for s, c in station.availability_by_size().items()})
# Small package fits in SMALL — should go there first.
small_pkg = Package(
id="P-001", recipient="alice@example.com",
dims=Dimensions(20, 20, 5), weight_grams=500,
deadline=datetime.utcnow() + timedelta(days=2),
)
res1, otp1 = station.drop_off(small_pkg)
assigned_size = next(l.size for l in lockers if l.id == res1.locker_id)
print(f"Small package -> {assigned_size.name} locker (code: {otp1})")
# Too-big package that's still valid for LARGE.
big_pkg = Package(
id="P-002", recipient="bob@example.com",
dims=Dimensions(55, 55, 40), weight_grams=8000,
deadline=datetime.utcnow() + timedelta(days=2),
)
res2, otp2 = station.drop_off(big_pkg)
print(f"Big package -> {next(l.size.name for l in lockers if l.id == res2.locker_id)}")
# Wrong OTP → rejected.
try:
station.pickup(res1.id, "000000")
except PermissionError as e:
print(f"Wrong OTP rejected: {e}")
# Correct OTP → returned package.
retrieved = station.pickup(res1.id, otp1)
print(f"Picked up: {retrieved.id}")
print("After pickup:", {s.name: c for s, c in station.availability_by_size().items()})
# Package too large for any locker.
huge_pkg = Package(
id="P-FRIDGE", recipient="carol@example.com",
dims=Dimensions(200, 80, 80), weight_grams=50000,
deadline=datetime.utcnow() + timedelta(days=2),
)
try:
station.drop_off(huge_pkg)
except RuntimeError as e:
print(f"Too-big package rejected: {e}")
# Package deadline earlier than the default 3-day hold caps the reservation.
tight_pkg = Package(
id="P-TIGHT", recipient="dan@example.com",
dims=Dimensions(15, 15, 5), weight_grams=200,
deadline=datetime.utcnow() + timedelta(hours=6),
)
res_tight, _ = station.drop_off(tight_pkg, hold_ttl=timedelta(days=3))
hours_to_expiry = (res_tight.expires_at - datetime.utcnow()).total_seconds() / 3600
print(f"Tight-deadline reservation expires in ~{hours_to_expiry:.1f}h (capped by package.deadline)")
assert hours_to_expiry < 7Interview Grading by Level
What an interviewer at each level expects to see in your answer. Use this to calibrate, not to perform.
Junior Engineer (L3)
Reaches a working drop-off/pickup design but allocation policy, OTP hashing, and expiry stay vague.
- Identifies that lockers come in sizes and packages need to fit.
- Adds an OTP to the pickup flow.
- Picks 'find any locker that fits' as the allocation method.
- Marks the locker as occupied during drop-off, free on pickup.
- Acknowledges that packages can't sit forever.
- Stores OTPs as plaintext in the reservation.
- Allocates the first available locker without considering size, lets shoeboxes land in XL slots.
- Compares dimensions raw (length-to-length) without rotation-awareness.
- Returns distinct errors ('expired' vs 'wrong code' vs 'unknown') on pickup, leaking existence.
- Skips the expiry sweep, lets unpicked packages occupy lockers indefinitely.
Mid-Level Engineer (L4)
Drives the design end-to-end with hashed OTPs, smallest-that-fits allocation, and explicit expiry via min(deadline, TTL).
- Implements SmallestThatFits with rotation-aware fits_inside (sort both tuples) and min-by-volume tiebreak.
- Hashes OTPs at generation, returns plaintext exactly once, verifies with a constant-time compare.
- Computes expires_at as min(package.deadline, now + hold_ttl) and explains why.
- Returns the same error for all pickup failure modes.
- Implements the sweep as the background reclaim for no-shows.
- Models AllocationStrategy as an interface with SmallestThatFits as the default.
- Tracks ReservationStatus (ACTIVE, COMPLETED, EXPIRED) as explicit transitions.
- Does not volunteer the returns flow or refrigerated category until asked.
- Misses per-OTP salt as a hardening detail.
- Treats multi-station 'nearest locker with room' as out of scope without sketching a StationRegistry.
Senior Engineer (L5+)
Volunteers returns and refrigerated categories before being asked, names the inventory-pyramid invariant, and frames each pattern around the failure it prevents.
- Volunteers the returns flow (same data model, flipped roles) and refrigerated Category (filter before size) unprompted.
- Names the inventory-pyramid invariant as the reason for smallest-that-fits, with the 'station fails by noon' scenario.
- Frames the patterns around failure modes: Strategy prevents 'a new holiday policy forks LockerStation', State prevents 'a malfunctioning locker still gets allocated', hashed OTP prevents 'database breach leaks half the station's pickup codes'.
- Defends the unified-error pickup rule as a defense against reservation-existence probing, not just hygiene.
- Proposes per-OTP salt (or argon2 in production) instead of the fixed salt.
- Proposes a StationRegistry sharded by geo for the 'nearest locker with room for my package' query.
- Closes with a one-sentence summary covering allocation, OTP, expiry, and sweep in under 25 seconds.
Common Mistakes
- ✗Assigning any available locker. A shoebox lands in a 3-foot locker and a fridge can't fit anywhere.
- ✗Plaintext OTPs in the database. Compromise leaks every active pickup code.
- ✗Forgetting a reservation step. Package gets assigned but then a race condition double-assigns the locker.
- ✗No expiry flow. Packages pile up; lockers run out; the system silently degrades.
Key Points
- ✓Locker state machine: AVAILABLE → RESERVED → OCCUPIED → AVAILABLE, or OCCUPIED → EXPIRED for no-shows.
- ✓Smallest-that-fits keeps large lockers free for packages that actually need them.
- ✓OTP is hashed — the station verifies by hashing input, never storing plaintext.
- ✓Expiry is lazy: packages past their pickup deadline get reclaimed by a periodic sweep.