Self-serve package pickup
Exact size match
7-day TTL
Validate + open
openExpired
Analogy: airport luggage locker
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 — list of compartments +
code → AccessTokenmap. 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_expiredfrees 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.
- Clarify: deposit by carrier, pickup by customer code, bay sizes, TTL before reclaim, staff override.
- v1: sized bays, deposit→code, pickup validates code, expire reclaim frees bay.
- Entities: LockerBank, Bay, PackageDeposit (code, bay, expiry).
- API: deposit(pkg_size)→code; pickup(code); reclaim_expired().
- deposit finds free fitting bay, marks occupied, stores code hash/id.
- pickup checks code, opens bay, clears occupancy.
- Trace: deposit medium, pickup success; wrong code fails; TTL pass then reclaim.
- Concurrency: lock around bay allocation.
- 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.