Amazon Locker Service
Lockers as a generalised parking lot for packages. Size matching, expiry, access codes, partner-delivery vs customer-pickup.
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
Ndays (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:
Lockercarries the state machine, notReservation. A locker is the long-lived artefact; reservations come and go.Empty → Reserved → Empty(pickup or expiry) is a clean cycle.Reservationcarries the access code and expiry, because they belong to the episode, not the locker.- Strategy pattern on
AllocationStrategy—ExactFitStrategy(only allocateSto anSpackage) vsBestFitStrategy(escalate to the next size up when the requested size is full). Package.sizeis normalised at intake. The package’s actual dimensions become aLockerSizebucket via the strategy; the locker only ever sees the bucket.
What is not in the diagram and that is deliberate:
- No
Usergod class. Partner and customer enter through different methods (dropOff,pickup) with different inputs (package vs code). Their identity layers stay separate. AccessCodeis not its own class. It is a value produced byAccessCodeProviderand 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
LockerisEmpty/Reserved/OutOfOrder; richer status lives onReservation. That is the right split — the locker is a long-lived resource, the reservation is an episode. AccessCodeProvideris injected. Tests use a deterministic provider; production uses a CSPRNG. HardcodingRandomhere is a tell.byCodeindex for O(1) pickup lookup. Pickup arrives with a code; an O(N) scan across thousands of reservations would not meet the latency budget.Clockis injected. The expiry sweep depends on “now”; tests fast-forward without sleeping.synchronizedon the service. Coarse, correct, and fast enough at one-kiosk scale. If kiosks ever share a service, partition the lock bybankId.
Trade-offs and extensions#
Decisions explicitly made and what they cost:
| Decision | Why | Cost if requirements change |
|---|---|---|
State on Locker, status on Reservation | Right split between long-lived and episodic concerns. | None at this scale. |
BestFitStrategy escalates to larger sizes when smaller is full | Honours 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 service | Throughput is well below the lock’s ceiling for one kiosk. | At regional scale, partition by bankId. |
| Six-digit access code | Conventional 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 timer | The 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 intake | The 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-use | The 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
expiresAtonce, capped at a maximum. New state on the reservation:Active → Extended → Active. Or, simpler, the reservation grows anextensionCountcounter. - 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 —
dropOffandpickupswap actors. - Locker out-of-order. Admin transitions
Empty → OutOfOrder(and back when serviced). The allocation strategy skipsOutOfOrderlockers; 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?” —
BestFitStrategyescalates 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?” —
dropOffissynchronized; 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
expiresAtas the reservation; the policy is configurable. - “What happens on an expired pickup attempt?” — The defensive branch in
pickupcatches the case where the sweep hasn’t run yet; the customer seesReservationExpiredException. 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
LockerServiceknowing them. (Observer pattern.) - “What changes when the design serves a region of thousands of kiosks?” — Persistence becomes mandatory; the
byCodeindex moves to a database or distributed cache; allocation stays per-kiosk; the regional routing layer is its own service. The in-kiosk design stays.
Related#
- Parking Lot — the structural sibling; size matching and slot allocation transfer almost verbatim.
- State Pattern — the locker and the reservation are both clean state machines.
- Strategy Pattern —
AllocationStrategyis the textbook example. - Observer Pattern — the answer to the “where would observers fit?” follow-up.
- Approaching the OOD Interview — the meta-script that produced this writeup’s structure.