Amazon Locker LLD deep dive

Design Amazon Locker

Full LLD for Amazon-style lockers: clarifying questions, exact-size match, 7-day TTL, staff openExpired, Locker/Compartment/AccessToken design (Package is not an entity), deposit/pickup flows, and extensibility.

Self-serve package pickup

An Amazon-style locker is a bank of sized compartments. A carrier deposits a package into a free bay; the system returns an access code; the customer opens that bay with the code. Unclaimed packages expire and staff reclaim them.
Prompt you’ll get: “Design a locker system where drivers deposit packages and customers pick up with a code.” Same ritual as every classic — clarify until deposit, pickup, and expiry are unambiguous.
01Deposit

Exact size match

02Token

7-day TTL

03Pickup

Validate + open

04Staff

openExpired

Locker deposit and pickup flow
Find bay → assign code → notify → pickup or expire / staff reclaim.

Analogy: airport luggage locker

Locker hold
Deposit → code → pickup (or expire).

Amazon Locker is an airport luggage locker wall: the carrier finds a free bay, drops the bag, texts you a code. The code is the claim ticket. Expiry = the airport clears abandoned lockers back to free.

Clarifying questions → locked requirements

  • Sizes? Exact match or fallback to larger? → Exact size for v1; reject if none free.
  • How does the customer open? → Access code returned on deposit.
  • Token lifetime? → 7 days; expired codes fail pickup.
  • Where does the package go after expiry? → Remains physically in the bay until staff openExpired clears it.
  • Out of scope? → Delivery logistics, SMS delivery of codes, UI, multi-station networks, payment.

Entities: Locker, Compartment, AccessToken — not Package

Locker entities
Locker orchestrates; Compartment owns physical occupancy; AccessToken carries TTL.
Package is not an entity. The system only needs the size as an input to deposit. We don’t track recipient, SKU, or contents. Modelling Package would invent state the requirements never mention.
  • Locker — list of compartments + code → AccessToken map. Public API: deposit_package, pickup, open_expired.
  • Compartment — size + physical occupancy flag (and later status). Occupancy is intrinsic: the package is physically there even after the token expires.
  • AccessToken — code, compartment id, expires_at. Owns “is expired?” logic.

Class design

from dataclasses import dataclass
from enum import Enum, auto
from datetime import datetime, timedelta
from typing import Optional
import secrets

class Size(Enum):
    SMALL = auto()
    MEDIUM = auto()
    LARGE = auto()

class Compartment:
    def __init__(self, compartment_id: str, size: Size):
        self.id, self.size = compartment_id, size
        self.occupied = False

class AccessToken:
    def __init__(self, code: str, compartment_id: str, expires_at: datetime):
        self.code, self.compartment_id = code, compartment_id
        self.expires_at = expires_at

    def is_expired(self, now: datetime) -> bool:
        return now >= self.expires_at

class Locker:
    TTL = timedelta(days=7)

    def __init__(self, compartments: list[Compartment]):
        self.compartments = {c.id: c for c in compartments}
        self.tokens: dict[str, AccessToken] = {}

    def deposit_package(self, size: Size) -> str: ...
    def pickup(self, token_code: str) -> None: ...
    def open_expired(self, now: Optional[datetime] = None) -> list[str]: ...
import java.time.*;
import java.util.*;
import java.security.SecureRandom;

enum Size { SMALL, MEDIUM, LARGE }

class Compartment {
    String id;
    Size size;
    boolean occupied = false;
    Compartment(String id, Size size) { this.id = id; this.size = size; }
}

class AccessToken {
    String code, compartmentId;
    Instant expiresAt;
    AccessToken(String code, String compartmentId, Instant expiresAt) {
        this.code = code; this.compartmentId = compartmentId; this.expiresAt = expiresAt;
    }
    boolean isExpired(Instant now) { return !now.isBefore(expiresAt); }
}

class Locker {
    static final Duration TTL = Duration.ofDays(7);
    Map<String, Compartment> compartments = new HashMap<>();
    Map<String, AccessToken> tokens = new HashMap<>();
    private final SecureRandom rng = new SecureRandom();

    Locker(List<Compartment> compartments) {
        for (Compartment c : compartments) this.compartments.put(c.id, c);
    }

    String depositPackage(Size size) { /* ... */ return null; }
    void pickup(String tokenCode) { /* ... */ }
    List<String> openExpired(Instant now) { /* ... */ return List.of(); }
}

Implementation highlights

def _find_available(self, size: Size) -> Optional[Compartment]:
    for c in self.compartments.values():
        if c.size is size and not c.occupied:
            return c
    return None

def _generate_token(self, compartment_id: str, now: datetime) -> AccessToken:
    code = secrets.token_hex(4)  # interview: any unique string
    while code in self.tokens:
        code = secrets.token_hex(4)
    token = AccessToken(code, compartment_id, now + self.TTL)
    self.tokens[code] = token
    return token

def _clear_deposit(self, token: AccessToken) -> None:
    c = self.compartments[token.compartment_id]
    c.occupied = False
    self.tokens.pop(token.code, None)

def deposit_package(self, size: Size, now: Optional[datetime] = None) -> str:
    now = now or datetime.utcnow()
    bay = self._find_available(size)
    if bay is None:
        raise RuntimeError("no matching compartment")
    bay.occupied = True
    return self._generate_token(bay.id, now).code

def pickup(self, token_code: str, now: Optional[datetime] = None) -> None:
    now = now or datetime.utcnow()
    token = self.tokens.get(token_code)
    if token is None:
        raise RuntimeError("invalid token")
    if token.is_expired(now):
        raise RuntimeError("token expired")
    self._clear_deposit(token)

def open_expired(self, now: Optional[datetime] = None) -> list[str]:
    now = now or datetime.utcnow()
    cleared = []
    for token in list(self.tokens.values()):
        if token.is_expired(now):
            cleared.append(token.compartment_id)
            self._clear_deposit(token)
    return cleared
Optional<Compartment> findAvailable(Size size) {
    for (Compartment c : compartments.values()) {
        if (c.size == size && !c.occupied) return Optional.of(c);
    }
    return Optional.empty();
}

AccessToken generateToken(String compartmentId, Instant now) {
    String code;
    do { code = Integer.toHexString(rng.nextInt()); } while (tokens.containsKey(code));
    AccessToken token = new AccessToken(code, compartmentId, now.plus(TTL));
    tokens.put(code, token);
    return token;
}

void clearDeposit(AccessToken token) {
    compartments.get(token.compartmentId).occupied = false;
    tokens.remove(token.code);
}

String depositPackage(Size size, Instant now) {
    if (now == null) now = Instant.now();
    Compartment bay = findAvailable(size).orElseThrow(() -> new RuntimeException("no matching compartment"));
    bay.occupied = true;
    return generateToken(bay.id, now).code;
}

void pickup(String tokenCode, Instant now) {
    if (now == null) now = Instant.now();
    AccessToken token = tokens.get(tokenCode);
    if (token == null) throw new RuntimeException("invalid token");
    if (token.isExpired(now)) throw new RuntimeException("token expired");
    clearDeposit(token);
}

List<String> openExpired(Instant now) {
    if (now == null) now = Instant.now();
    List<String> cleared = new ArrayList<>();
    for (AccessToken token : new ArrayList<>(tokens.values())) {
        if (token.isExpired(now)) {
            cleared.add(token.compartmentId);
            clearDeposit(token);
        }
    }
    return cleared;
}

Verification

  • Deposit — SMALL into empty SMALL bay → occupied, token returned; second SMALL with no free SMALL → error.
  • Pickup — valid code → bay free, token gone; wrong code → invalid; after TTL → expired (bay still occupied).
  • Expired — open_expired frees all bays whose tokens are past TTL and returns their ids.
  • Happy: Carrier deposits medium package → bay reserved → customer enters code → bay opens → FREE.
  • Failure: No fitting bay → deposit fails; wrong code → pickup fails without revealing bay map.
  • Concurrency: Two deposits race last medium bay — allocation lock ensures one success, one “full”.

Extensibility

  • Size fallback — scan exact size, then larger sizes only (never smaller).
  • OUT_OF_SERVICE — compartment status enum AVAILABLE / OCCUPIED / OUT_OF_SERVICE; availability checks skip OOS bays.
  • Two-phase reserve/confirm — reserve opens the door; confirm (or sensor) commits occupancy + token. Adds states; mention for production realism, don’t build unless asked.

Common interview pitfalls

These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.

  • Treating locker like a warehouse WMS — overmodeling carriers, routes, and payments.
  • No token/code on deposit — pickup becomes “trust the name on the package.”
  • Forgetting bay size matching (package vs compartment).
  • Missing expire/reclaim path when customer never picks up.
  • Shared mutable “open bay” without state machine FREE→OCCUPIED→…
  • Ignoring concurrent deposit trying to grab the same free bay.

Interview script (say this)

Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.

  1. Clarify: deposit by carrier, pickup by customer code, bay sizes, TTL before reclaim, staff override.
  2. v1: sized bays, deposit→code, pickup validates code, expire reclaim frees bay.
  3. Entities: LockerBank, Bay, PackageDeposit (code, bay, expiry).
  4. API: deposit(pkg_size)→code; pickup(code); reclaim_expired().
  5. deposit finds free fitting bay, marks occupied, stores code hash/id.
  6. pickup checks code, opens bay, clears occupancy.
  7. Trace: deposit medium, pickup success; wrong code fails; TTL pass then reclaim.
  8. Concurrency: lock around bay allocation.
  9. Extensions: multi-site banks, SMS code rotation, refrigerated bays as BayCapability.

Extra verification traces

Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.

def deposit(self, size: Size) -> str:
    with self.lock:
        bay = self.find_free(size)
        if not bay:
            raise RuntimeError("no bay")
        code = self.new_code()
        bay.occupy(code, expires_at=self.now() + self.ttl)
        return code
String deposit(Size size) {
    lock.lock();
    try {
        Bay bay = findFree(size);
        if (bay == null) throw new RuntimeException("no bay");
        String code = newCode();
        bay.occupy(code, now().plus(ttl));
        return code;
    } finally {
        lock.unlock();
    }
}

Staff-level follow-ups

At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.

  • Multiple locker locations? — LockerNetwork routes by geo; LockerBank stays local bay owner.
  • Code phishing? — One-time codes + attempt throttle; don’t return bay id on failure.
  • Partial open hardware fail? — Bay state OPENING with timeout → staff alert; don’t mark FREE early.
  • Reservation before arrive? — HELD bay with TTL similar to seat holds.

Complete solution (deposit / pickup with lock)

from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum, auto
from threading import Lock
from typing import Optional
import secrets

class BaySize(Enum):
    S = auto(); M = auto(); L = auto()

class BayState(Enum):
    FREE = auto(); OCCUPIED = auto()

@dataclass
class Bay:
    id: str
    size: BaySize
    state: BayState = BayState.FREE

@dataclass
class Deposit:
    code: str
    bay_id: str
    expires_at: datetime

class LockerBank:
    FITS = {
        BaySize.S: {BaySize.S},
        BaySize.M: {BaySize.S, BaySize.M},
        BaySize.L: {BaySize.S, BaySize.M, BaySize.L},
    }

    def __init__(self, bays: list[Bay], hold_hours: int = 48):
        self.bays = {b.id: b for b in bays}
        self.deposits: dict[str, Deposit] = {}
        self.hold_hours = hold_hours
        self._lock = Lock()

    def deposit(self, package_size: BaySize, now: Optional[datetime] = None) -> str:
        now = now or datetime.utcnow()
        with self._lock:
            bay = next(
                (b for b in self.bays.values()
                 if b.state is BayState.FREE and package_size in self.FITS[b.size]),
                None,
            )
            if bay is None:
                raise RuntimeError("no bay")
            bay.state = BayState.OCCUPIED
            code = secrets.token_hex(3)
            self.deposits[code] = Deposit(
                code, bay.id, now + timedelta(hours=self.hold_hours)
            )
            return code

    def pickup(self, code: str, now: Optional[datetime] = None) -> str:
        now = now or datetime.utcnow()
        with self._lock:
            d = self.deposits.pop(code, None)
            if d is None:
                raise RuntimeError("bad code")
            if now > d.expires_at:
                # still free the bay; signal expired reclaim path
                self.bays[d.bay_id].state = BayState.FREE
                raise RuntimeError("expired")
            self.bays[d.bay_id].state = BayState.FREE
            return d.bay_id
import java.time.*;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.security.SecureRandom;

enum BaySize { S, M, L }
enum BayState { FREE, OCCUPIED }

class Bay {
    String id;
    BaySize size;
    BayState state = BayState.FREE;
    Bay(String id, BaySize size) { this.id = id; this.size = size; }
}

class Deposit {
    String code, bayId;
    Instant expiresAt;
    Deposit(String code, String bayId, Instant expiresAt) {
        this.code = code; this.bayId = bayId; this.expiresAt = expiresAt;
    }
}

class LockerBank {
    static final Map<BaySize, Set<BaySize>> FITS = Map.of(
        BaySize.S, Set.of(BaySize.S),
        BaySize.M, Set.of(BaySize.S, BaySize.M),
        BaySize.L, Set.of(BaySize.S, BaySize.M, BaySize.L)
    );

    Map<String, Bay> bays = new HashMap<>();
    Map<String, Deposit> deposits = new HashMap<>();
    int holdHours;
    private final ReentrantLock lock = new ReentrantLock();
    private final SecureRandom rng = new SecureRandom();

    LockerBank(List<Bay> bays, int holdHours) {
        for (Bay b : bays) this.bays.put(b.id, b);
        this.holdHours = holdHours;
    }

    String deposit(BaySize packageSize, Instant now) {
        if (now == null) now = Instant.now();
        lock.lock();
        try {
            Bay bay = null;
            for (Bay b : bays.values()) {
                if (b.state == BayState.FREE && FITS.get(b.size).contains(packageSize)) {
                    bay = b; break;
                }
            }
            if (bay == null) throw new RuntimeException("no bay");
            bay.state = BayState.OCCUPIED;
            String code = Integer.toHexString(rng.nextInt(0xffffff));
            deposits.put(code, new Deposit(code, bay.id, now.plus(Duration.ofHours(holdHours))));
            return code;
        } finally {
            lock.unlock();
        }
    }

    String pickup(String code, Instant now) {
        if (now == null) now = Instant.now();
        lock.lock();
        try {
            Deposit d = deposits.remove(code);
            if (d == null) throw new RuntimeException("bad code");
            bays.get(d.bayId).state = BayState.FREE;
            if (now.isAfter(d.expiresAt)) throw new RuntimeException("expired");
            return d.bayId;
        } finally {
            lock.unlock();
        }
    }
}

Concurrency cases

  • Two deposits, one small bay — same find+claim race as parking.
  • Pickup vs expire job — both under bank lock so a code isn’t popped twice.
  • Notify SMS outside the lock — generate code under lock, send message after release.

← Lattice