Amazon Locker Service

Lockers as a generalised parking lot for packages. Size matching, expiry, access codes, partner-delivery vs customer-pickup.

System Intermediate
13 min read
ood case-study amazon-locker state-pattern strategy-pattern

Context#

Amazon Locker is an unattended package-pickup network: delivery partners drop packages into roadside or in-store locker banks, and customers retrieve them later with a one-time access code. The system is structurally a generalised parking lot — slots of varied sizes, a request that needs allocation, a ticket-like artefact for retrieval — but with two flows (drop-off and pickup) instead of one, an explicit expiry policy, and a dual-actor identity model that does not exist in the parking lot.

The interviewer’s hidden objectives, in roughly the order they will be tested:

  • Can you see the analogy to parking lots and reuse what fits without forcing a one-to-one mapping?
  • Can you identify the entities — locker, locker-bank (location), package, reservation, access code, expiry — and the relationships?
  • Can you produce a class diagram that uses the State pattern on the locker (or the reservation) for the drop-off → awaiting-pickup → expired lifecycle?
  • Can you defend size matching with a clean strategy and explain when it gets harder (no L locker free, M will fit)?
  • Can you separate the partner-delivery flow from the customer-pickup flow without two giant if (actor == partner) switches?

Requirements (functional and non-functional)#

Scoping is the heaviest-weighted moment of the round. The defaults below are what most interviewers expect; flag anything outside them.

Functional — in scope.

  • A locker bank is a physical kiosk with many lockers in fixed sizes (S, M, L, XL).
  • A delivery partner drops off a package: the system allocates a compatible free locker and issues a one-time access code to the customer.
  • The customer picks up the package by entering the code at the kiosk; the locker unlocks once and the reservation is closed.
  • A reservation expires if not picked up within N days (default 3). On expiry, the locker is reclaimed and the package re-routed to a return depot.
  • Each kiosk reports availability per size; the routing layer (out of scope) uses this to direct partners.

Functional — out of scope (called out explicitly). Subscriptions, refrigerated lockers, multi-package reservations, customer-side rescheduling, the wide-area routing problem that decides which kiosk to send a partner to. Mentioned so the interviewer knows you saw them; not discussed unless asked.

Non-functional.

  • A kiosk has 10–100 lockers; a region has thousands of kiosks. The design models one kiosk; the regional aggregation is a thin layer above.
  • Drop-off and pickup decisions in well under 100 ms — a partner is holding the package, a customer is at the door.
  • Concurrency: a kiosk has one physical door but many software entry points (partner app, customer app, admin panel). Two drop-offs must not be assigned the same locker.
  • Security: the access code is short-lived, single-use, and large enough to make brute force infeasible (six digits is conventional; the policy is configurable).

Use case diagram#

┌──────────────────┐ ┌──────────────────┐
│ Delivery Partner │ │ Customer │
└────────┬─────────┘ └────────┬─────────┘
│ │
▼ ▼
[drop off package] [pick up package]
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ Locker Service │
└─────────────────┬────────────────────────────────┘
┌───────────────────┐
│ Kiosk Admin │ … reclaim expired, mark out-of-order
└───────────────────┘

Three actors (Partner, Customer, Admin) and two primary use cases driven by the first two. Admin actions are explicit because expiry-reclaim is not automatic in the simplest design — an admin sweep can run on a schedule too.

Class diagram#

┌─────────────────────────────┐
│ LockerService │
├─────────────────────────────┤
│ banks : List<LockerBank> │
│ allocation : AllocationStrategy │
│ codes : AccessCodeProvider │
│ expiryHours : int │
├─────────────────────────────┤
│ dropOff(bankId, pkg) │
│ pickup(code) │
│ sweepExpired(now) │
└──────────┬──────────────────┘
│ 1..*
┌────────────────────┐
│ LockerBank │
├────────────────────┤
│ id, location │
│ lockers : List<Locker> │
├────────────────────┤
│ findFree(size) │
└────────┬───────────┘
│ 1..*
┌────────────────────────┐
│ Locker │ ◇──── state: LockerState
├────────────────────────┤ (Empty / Reserved / OutOfOrder)
│ id, size : LockerSize │
│ reservation? : Reservation │
└────────────────────────┘
┌──────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ Package │ │ Reservation │ │ AllocationStrategy │◁── ExactFit, BestFit
├──────────────────┤ ├────────────────────────┤ ├────────────────────────┤
│ id, dims │ │ id, package │ │ choose(bank, size) │
│ size : LockerSize│ │ locker, code │ └────────────────────────┘
└──────────────────┘ │ createdAt, expiresAt │
│ status : ReservationStatus │
└────────────────────────┘

The load-bearing decisions:

  • Locker carries the state machine, not Reservation. A locker is the long-lived artefact; reservations come and go. Empty → Reserved → Empty (pickup or expiry) is a clean cycle.
  • Reservation carries the access code and expiry, because they belong to the episode, not the locker.
  • Strategy pattern on AllocationStrategyExactFitStrategy (only allocate S to an S package) vs BestFitStrategy (escalate to the next size up when the requested size is full).
  • Package.size is normalised at intake. The package’s actual dimensions become a LockerSize bucket via the strategy; the locker only ever sees the bucket.

What is not in the diagram and that is deliberate:

  • No User god class. Partner and customer enter through different methods (dropOff, pickup) with different inputs (package vs code). Their identity layers stay separate.
  • AccessCode is not its own class. It is a value produced by AccessCodeProvider and carried on the reservation. Wrapping it in a class adds no behaviour.

Sequence diagram (key flows)#

The partner drop-off flow:

Partner Kiosk LockerService LockerBank Locker Reservation
│ drop(pkg) │ │ │ │ │
│─────────────►│ │ │ │ │
│ │ dropOff(bank,pkg)│ │ │ │
│ │─────────────────►│ │ │ │
│ │ │ findFree(size) │ │ │
│ │ │───────────────►│ │ │
│ │ │ locker │ │ │
│ │ │◄───────────────│ │ │
│ │ │ reserve(pkg, expiresAt) ────────────────────►│
│ │ │ new code via AccessCodeProvider │
│ │ │ locker.markReserved(reservation) ──────────►│
│ │ unlock(locker) │ │ │ │
│ │◄─────────────────│ │ │ │
│ │ open door │ │ │ │
│ deposit pkg, │ │ │ │ │
│ close door │ │ │ │ │

The customer pickup flow:

Customer Kiosk LockerService Reservation Locker
│ enter code │ │ │ │
│─────────────►│ │ │ │
│ │ pickup(code) │ │ │
│ │─────────────────►│ │ │
│ │ │ findByCode(c)──►│ │
│ │ │ r │ │
│ │ │◄───────────────│ │
│ │ │ verifyNotExpired(r, now) │
│ │ │ open(r.locker) ─────────────────►│
│ │ │ r.markPickedUp() ──────────────►│
│ │ │ locker.markEmpty() ────────────►│
│ │ door opens │ │ │
│ │◄─────────────────│ │ │

The expiry sweep (admin or scheduler):

Sweeper LockerService Reservation Locker
│ sweepExpired(now) │ │
│────────────────────►│ │ │
│ │ for each Reserved locker │
│ │ if r.expiresAt < now │
│ │ r.markExpired() ────────►│
│ │ locker.markEmpty() ─────►│
│ │ enqueue(pkg) -> ReturnDepot │

Activity diagram (for non-trivial state)#

The reservation lifecycle (which mirrors the locker’s Reserved sub-states):

┌─────────┐
│ start │
└────┬────┘
┌────────────────┐
│ Active │── pickup with valid code ──┐
└────────┬───────┘ │
│ ▼
expiry sweep ┌────────────────┐
│ │ Picked Up │
▼ └────────────────┘
┌────────────────┐
│ Expired │── package routed to return depot
└────────────────┘
(terminal — locker becomes Empty)

Both terminal states (PickedUp, Expired) free the locker. A reservation cannot pickup and expire — the sweep checks the status before transitioning.

Java implementation#

A representative slice — the locker state, allocation, drop-off, and pickup — not the full system.

public enum LockerSize { S, M, L, XL }
public enum LockerState { EMPTY, RESERVED, OUT_OF_ORDER }
public enum ReservationStatus { ACTIVE, PICKED_UP, EXPIRED }
public final class Locker {
private final String id;
private final LockerSize size;
private LockerState state = LockerState.EMPTY;
private Reservation current;
public Locker(String id, LockerSize size) { this.id = id; this.size = size; }
public synchronized boolean isFree() { return state == LockerState.EMPTY; }
public LockerSize size() { return size; }
public synchronized void markReserved(Reservation r) {
if (state != LockerState.EMPTY) throw new IllegalStateException("Locker " + id + " not empty");
this.current = r;
this.state = LockerState.RESERVED;
}
public synchronized void markEmpty() {
this.current = null;
this.state = LockerState.EMPTY;
}
}
public final class Reservation {
private final String id;
private final Package pkg;
private final Locker locker;
private final String code;
private final Instant createdAt;
private final Instant expiresAt;
private ReservationStatus status = ReservationStatus.ACTIVE;
public Reservation(String id, Package p, Locker l, String code,
Instant createdAt, Instant expiresAt) {
this.id = id; this.pkg = p; this.locker = l; this.code = code;
this.createdAt = createdAt; this.expiresAt = expiresAt;
}
public void markPickedUp() {
if (status != ReservationStatus.ACTIVE) throw new IllegalStateException("Not active: " + status);
status = ReservationStatus.PICKED_UP;
}
public void markExpired() {
if (status != ReservationStatus.ACTIVE) throw new IllegalStateException("Not active: " + status);
status = ReservationStatus.EXPIRED;
}
public boolean isExpired(Instant now) { return status == ReservationStatus.ACTIVE && now.isAfter(expiresAt); }
public String code() { return code; }
public Locker locker() { return locker; }
public Package pkg() { return pkg; }
public ReservationStatus status() { return status; }
}
public interface AllocationStrategy {
Optional<Locker> choose(LockerBank bank, LockerSize requested);
}
public final class BestFitStrategy implements AllocationStrategy {
private static final List<LockerSize> ORDER = List.of(LockerSize.S, LockerSize.M, LockerSize.L, LockerSize.XL);
public Optional<Locker> choose(LockerBank bank, LockerSize requested) {
for (LockerSize s : ORDER.subList(ORDER.indexOf(requested), ORDER.size())) {
Optional<Locker> hit = bank.firstFreeOfSize(s);
if (hit.isPresent()) return hit;
}
return Optional.empty();
}
}
public final class LockerService {
private final Map<String, LockerBank> banks;
private final AllocationStrategy allocation;
private final AccessCodeProvider codes;
private final Map<String, Reservation> byCode = new ConcurrentHashMap<>();
private final Clock clock;
private final Duration expiry;
public LockerService(Map<String, LockerBank> banks, AllocationStrategy a,
AccessCodeProvider codes, Clock clock, Duration expiry) {
this.banks = banks; this.allocation = a; this.codes = codes;
this.clock = clock; this.expiry = expiry;
}
public synchronized Reservation dropOff(String bankId, Package pkg) {
LockerBank bank = banks.get(bankId);
if (bank == null) throw new UnknownBankException(bankId);
Locker locker = allocation.choose(bank, pkg.size())
.orElseThrow(() -> new NoLockerAvailableException(bankId, pkg.size()));
Instant now = clock.instant();
Reservation r = new Reservation(UUID.randomUUID().toString(),
pkg, locker, codes.generate(), now, now.plus(expiry));
locker.markReserved(r);
byCode.put(r.code(), r);
return r;
}
public synchronized Package pickup(String code) {
Reservation r = byCode.get(code);
if (r == null) throw new InvalidCodeException();
if (r.isExpired(clock.instant())) {
// sweep would normally have caught this; defensive here
r.markExpired();
r.locker().markEmpty();
byCode.remove(code);
throw new ReservationExpiredException();
}
r.markPickedUp();
r.locker().markEmpty();
byCode.remove(code);
return r.pkg();
}
public synchronized void sweepExpired(Instant now) {
Iterator<Map.Entry<String, Reservation>> it = byCode.entrySet().iterator();
while (it.hasNext()) {
Reservation r = it.next().getValue();
if (r.isExpired(now)) {
r.markExpired();
r.locker().markEmpty();
it.remove();
// enqueue r.pkg() to ReturnDepot here (out of scope)
}
}
}
}

Notes the interviewer will look for:

  • State on Locker is Empty/Reserved/OutOfOrder; richer status lives on Reservation. That is the right split — the locker is a long-lived resource, the reservation is an episode.
  • AccessCodeProvider is injected. Tests use a deterministic provider; production uses a CSPRNG. Hardcoding Random here is a tell.
  • byCode index for O(1) pickup lookup. Pickup arrives with a code; an O(N) scan across thousands of reservations would not meet the latency budget.
  • Clock is injected. The expiry sweep depends on “now”; tests fast-forward without sleeping.
  • synchronized on the service. Coarse, correct, and fast enough at one-kiosk scale. If kiosks ever share a service, partition the lock by bankId.

Trade-offs and extensions#

Decisions explicitly made and what they cost:

DecisionWhyCost if requirements change
State on Locker, status on ReservationRight split between long-lived and episodic concerns.None at this scale.
BestFitStrategy escalates to larger sizes when smaller is fullHonours the contract (the package fits) at the cost of wasting space.If wasted-space cost matters, add a StrictFitStrategy that refuses to escalate.
Single coarse synchronized on the serviceThroughput is well below the lock’s ceiling for one kiosk.At regional scale, partition by bankId.
Six-digit access codeConventional and human-readable.If brute force is a concern, lengthen the code and add a kiosk-side rate limit on failed attempts.
Expiry handled by a sweep, not a timerThe sweep is testable, idempotent, and survives a process restart.If real-time eviction matters, add a delay-queue index to free the locker the instant the deadline passes.
Package dimensions bucketed at intakeThe locker only knows sizes, not dims. SRP — dimensions belong upstream.If routing wants per-dimension stats, expose dims as metadata; the bucketing stays.
One-time codes, single-useThe contract for a customer is “pick up once”.For multi-package reservations, allow code reuse until all packages collected; the reservation grows a list.

Likely follow-up extensions and the shape of the answer:

  • Refrigerated lockers. New LockerType (ambient / refrigerated / frozen) orthogonal to size. The allocation strategy now matches both axes; existing classes untouched.
  • Customer reschedule. A reservation can extend its expiresAt once, capped at a maximum. New state on the reservation: Active → Extended → Active. Or, simpler, the reservation grows an extensionCount counter.
  • Returns (customer drops a package for pickup by partner). The flow reverses: customer creates a return reservation, partner picks up with a code. The classes already accommodate this — dropOff and pickup swap actors.
  • Locker out-of-order. Admin transitions Empty → OutOfOrder (and back when serviced). The allocation strategy skips OutOfOrder lockers; reservation flows are unaffected.
  • Multi-locker reservation (one package too big for any single locker). Reservation grows from one locker to a list; the access code unlocks all of them. The data model accommodates this; the kiosk UX is the harder bit.

Mock interview follow-ups#

Questions interviewers reach for and the briefest correct answer:

  • “Where does this differ from a parking lot?” — Two flows instead of one, an explicit expiry, and a dual-actor identity model. The slot-allocation core is shared; the lifecycle around it is richer.
  • “What’s the state machine on the reservation?”Active → (Picked Up | Expired). Both terminal; both free the locker. Show the activity diagram.
  • “How do you size-match when the exact size is full?”BestFitStrategy escalates to the next bucket up. The contract is “the package fits”, not “the smallest possible locker”.
  • “What if two partners drop off at the same instant?”dropOff is synchronized; the second call sees fewer free lockers (or none) and proceeds or fails accordingly. At regional scale, partition the lock per bank.
  • “How long does the access code live?” — Until pickup, expiry, or the maximum lifetime — whichever fires first. Default is the same expiresAt as the reservation; the policy is configurable.
  • “What happens on an expired pickup attempt?” — The defensive branch in pickup catches the case where the sweep hasn’t run yet; the customer sees ReservationExpiredException. The locker is freed at that moment too — the sweep is a backstop, not the only path.
  • “Where would observers fit?” — On reservation state transitions, so customer notifications (code SMS on creation, expiry warning on day 2, return-routing on expiry) subscribe without LockerService knowing them. (Observer pattern.)
  • “What changes when the design serves a region of thousands of kiosks?” — Persistence becomes mandatory; the byCode index moves to a database or distributed cache; allocation stays per-kiosk; the regional routing layer is its own service. The in-kiosk design stays.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.