10 — LLD: Amazon Locker System
Understanding the Problem
Amazon Locker is a self-service delivery system where packages are delivered to secure lockers at public locations (malls, grocery stores, gas stations). Customers receive a pickup code, open the locker, grab their package, and leave. If they do not pick up within a time window, the package is returned to the sender.
What is Amazon Locker?
Think of it as a vending machine in reverse — instead of dispensing products you buy on the spot, it holds packages that were ordered online. The system must match packages to appropriately sized lockers, handle concurrent deliveries, and enforce pickup deadlines.
Requirements
Clarifying Questions
You: So the core flow is: a delivery agent drops off a package, the customer gets a code, and they use that code to open the locker. Is that right?
Interviewer: Yes, that is the happy path.
You: How do we decide which locker a package goes into? Is it purely by size, or are there other factors like proximity to the customer?
Interviewer: Size-based allocation is the primary concern. The system picks the smallest available locker that fits the package.
You: What locker sizes should we support?
Interviewer: Let us say Small, Medium, and Large for now.
You: What happens if a customer never picks up their package?
Interviewer: After a configurable timeout (say 3 days), the package is marked for return to sender and the locker is freed.
You: Do we need to handle multiple locker locations?
Interviewer: Yes. Each location has its own set of lockers with a mix of sizes.
You: Should I worry about concurrent access — like two delivery agents trying to allocate the same locker at the same time?
Interviewer: Yes, that is important. You should handle it.
You: What about authentication for pickup? OTP, barcode, both?
Interviewer: A unique pickup code is enough. Think of it as a 6-digit code sent to the customer.
Final Requirements
- Multiple locker locations, each with a configurable mix of Small, Medium, and Large lockers.
- Size-based allocation — assign the smallest available locker that fits the package.
- Delivery flow — delivery agent scans package, system allocates locker, agent places package.
- Pickup flow — customer enters code, system validates, locker opens.
- Timeout and return — if not picked up within N hours, mark for return and free locker.
- Unique pickup codes per delivery.
- Thread-safe locker allocation (no two packages assigned the same locker concurrently).
Out of scope: Payment processing, delivery routing, customer notifications (we assume they happen externally), physical hardware control.
Core Entities and Relationships
| Entity | Responsibility |
|---|---|
LockerSize | Enum defining locker dimensions (SMALL, MEDIUM, LARGE) |
Locker | Represents a single physical locker — knows its size and whether it is occupied |
LockerLocation | A physical site containing multiple lockers — manages allocation and deallocation |
Package | Represents a package with size info — the thing being stored |
PickupCode | A unique code tied to a specific package-locker assignment |
LockerAssignment | Links a package to a locker with timestamps and pickup code |
LockerService | Orchestrates the delivery and pickup flows across locations |
CodeGenerator | Generates unique pickup codes |
Why does LockerAssignment deserve to be its own class? Because it captures the temporal relationship between a package and a locker. The locker exists before and after the assignment. The package exists independently. The assignment is what ties them together with a pickup code, delivery time, and expiry.
Why not put allocation logic inside Locker? A single locker does not know about other lockers. Allocation requires choosing among multiple lockers, so that responsibility belongs to LockerLocation.
Class Design
LockerSize (Enum)
SMALL → fits packages up to 10x8x5
MEDIUM → fits packages up to 16x12x10
LARGE → fits packages up to 24x18x15We define an ordering so SMALL < MEDIUM < LARGE, which allows the "smallest fit" allocation logic.
Locker
State:
lockerId: stringsize: LockerSizeisOccupied: boolean
Methods:
occupy()— marks locker as occupiedrelease()— marks locker as available
A locker is deliberately simple. It is a physical container. It does not know about packages, codes, or deadlines. That is separation of concerns.
Package
State:
packageId: stringsize: LockerSize— the minimum locker size neededrecipientId: string
LockerAssignment
State:
package: Packagelocker: LockerpickupCode: stringdeliveredAt: DateexpiresAt: DatepickedUp: boolean
Methods:
isExpired(now)— checks if the pickup window has passedmarkPickedUp()— flags as collected
LockerLocation
State:
locationId: stringaddress: stringlockers: Locker[]activeAssignments: Map<string, LockerAssignment>— keyed by pickup codelock: Mutex
Methods:
allocateLocker(packageSize) -> Locker— finds smallest available locker that fitsreleaseLocker(locker)— frees a lockerfindAssignmentByCode(code) -> LockerAssignmentcleanupExpired(now)— scans for expired assignments
LockerService
Methods:
deliverPackage(locationId, package) -> PickupCode— orchestrates deliverypickupPackage(locationId, code) -> Package— orchestrates pickuprunExpiryCheck()— periodic cleanup
Final Class Design
enum LockerSize {
SMALL = 1,
MEDIUM = 2,
LARGE = 3,
}
class Locker {
public isOccupied: boolean = false;
constructor(
public readonly lockerId: string,
public readonly size: LockerSize,
) {}
occupy(): void {
this.isOccupied = true;
}
release(): void {
this.isOccupied = false;
}
}
class Package {
constructor(
public readonly packageId: string,
public readonly size: LockerSize,
public readonly recipientId: string,
) {}
}
class LockerAssignment {
public pickedUp: boolean = false;
constructor(
public readonly package_: Package,
public readonly locker: Locker,
public readonly pickupCode: string,
public readonly deliveredAt: Date,
public readonly expiresAt: Date,
) {}
isExpired(now: Date): boolean {
return now > this.expiresAt && !this.pickedUp;
}
markPickedUp(): void {
this.pickedUp = true;
}
}Design principles at play:
- Single Responsibility: Each class does one thing.
Lockermanages its own state.LockerLocationmanages allocation across lockers.LockerServiceorchestrates flows. - Open/Closed: Adding a new locker size (e.g., EXTRA_LARGE) requires only extending the enum and adding physical lockers — no changes to allocation logic because it uses comparison operators.
- Composition over Inheritance:
LockerAssignmentcomposesPackageandLockerrather than inheriting from either.
Implementation
Core Logic: Locker Allocation
The allocation strategy is "best fit" — find the smallest available locker that can hold the package. This minimizes wasted space.
Bad approach: Randomly pick any available locker.
- Wastes large lockers on small packages. You run out of large lockers fast.
Good approach: Sort available lockers by size, pick the first one >= package size.
- Works, but sorting every time is O(n log n).
Great approach: Group lockers by size. Check the package's size bucket first, then try larger buckets in order.
- O(1) lookup per size bucket. If SMALL is full, try MEDIUM, then LARGE.
class LockerLocation {
public readonly locationId: string;
public readonly address: string;
public readonly lockers: Locker[];
public activeAssignments: Map<string, LockerAssignment> = new Map();
private availableBySize: Map<LockerSize, Locker[]>;
constructor(locationId: string, address: string, lockers: Locker[]) {
this.locationId = locationId;
this.address = address;
this.lockers = lockers;
// Group lockers by size for O(1) allocation
this.availableBySize = new Map<LockerSize, Locker[]>([
[LockerSize.SMALL, []],
[LockerSize.MEDIUM, []],
[LockerSize.LARGE, []],
]);
for (const locker of lockers) {
this.availableBySize.get(locker.size)!.push(locker);
}
}
allocateLocker(packageSize: LockerSize): Locker | null {
// Find the smallest available locker that fits the package
const sizes = [LockerSize.SMALL, LockerSize.MEDIUM, LockerSize.LARGE];
for (const size of sizes) {
if (size >= packageSize) {
const available = this.availableBySize.get(size)!;
if (available.length > 0) {
const locker = available.pop()!;
locker.occupy();
return locker;
}
}
}
return null; // No locker available
}
releaseLocker(locker: Locker): void {
locker.release();
this.availableBySize.get(locker.size)!.push(locker);
}
addAssignment(assignment: LockerAssignment): void {
this.activeAssignments.set(assignment.pickupCode, assignment);
}
findAssignmentByCode(code: string): LockerAssignment | undefined {
return this.activeAssignments.get(code);
}
removeAssignment(code: string): void {
this.activeAssignments.delete(code);
}
cleanupExpired(now: Date): LockerAssignment[] {
const expired: LockerAssignment[] = [];
const codesToRemove: string[] = [];
for (const [code, assignment] of this.activeAssignments) {
if (assignment.isExpired(now)) {
expired.push(assignment);
codesToRemove.push(code);
this.releaseLocker(assignment.locker);
}
}
for (const code of codesToRemove) {
this.activeAssignments.delete(code);
}
return expired;
}
}Core Logic: Code Generation
class CodeGenerator {
private usedCodes: Set<string> = new Set();
generate(length: number = 6): string {
while (true) {
let code = "";
for (let i = 0; i < length; i++) {
code += Math.floor(Math.random() * 10).toString();
}
if (!this.usedCodes.has(code)) {
this.usedCodes.add(code);
return code;
}
}
}
releaseCode(code: string): void {
this.usedCodes.delete(code);
}
}Core Logic: LockerService (Orchestrator)
class LockerService {
public locations: Map<string, LockerLocation> = new Map();
private codeGenerator = new CodeGenerator();
private pickupWindowMs: number;
constructor(pickupWindowHours: number = 72) {
this.pickupWindowMs = pickupWindowHours * 60 * 60 * 1000;
}
addLocation(location: LockerLocation): void {
this.locations.set(location.locationId, location);
}
deliverPackage(locationId: string, pkg: Package): string {
const location = this.locations.get(locationId);
if (!location) {
throw new Error(`Unknown location: ${locationId}`);
}
const locker = location.allocateLocker(pkg.size);
if (!locker) {
throw new Error(
`No available locker at ${locationId} for size ${LockerSize[pkg.size]}`
);
}
const now = new Date();
const code = this.codeGenerator.generate();
const assignment = new LockerAssignment(
pkg,
locker,
code,
now,
new Date(now.getTime() + this.pickupWindowMs),
);
location.addAssignment(assignment);
return code;
}
pickupPackage(locationId: string, code: string): Package {
const location = this.locations.get(locationId);
if (!location) {
throw new Error(`Unknown location: ${locationId}`);
}
const assignment = location.findAssignmentByCode(code);
if (!assignment) {
throw new Error("Invalid pickup code");
}
if (assignment.pickedUp) {
throw new Error("Package already picked up");
}
if (assignment.isExpired(new Date())) {
throw new Error("Pickup window has expired");
}
assignment.markPickedUp();
location.releaseLocker(assignment.locker);
location.removeAssignment(code);
this.codeGenerator.releaseCode(code);
return assignment.package_;
}
runExpiryCheck(): void {
const now = new Date();
for (const location of this.locations.values()) {
const expired = location.cleanupExpired(now);
for (const assignment of expired) {
this.codeGenerator.releaseCode(assignment.pickupCode);
// In production: trigger return-to-sender notification
}
}
}
}Edge Cases
- All lockers full —
allocateLockerreturnsnull, service raises an error. The delivery agent must try another location. - Expired code used for pickup — Caught in
pickupPackagebefore opening the locker. - Same code entered at wrong location — Codes are per-location, so the lookup fails.
- Concurrent delivery agents — In a production TypeScript/Node.js environment, you would use a mutex or async lock to ensure only one agent gets a given locker. Node.js is single-threaded for synchronous code, but async operations need explicit locking.
- Package larger than any locker — No locker with
size >= packageSizeexists, allocation returnsnull.
Verification
Let us trace through a complete delivery and pickup flow.
Setup: Location "LOC-1" has 2 SMALL lockers (S1, S2), 1 MEDIUM locker (M1), 1 LARGE locker (L1).
Step 1: Deliver a small package.
- Package P1 (size=SMALL) arrives at LOC-1.
allocateLocker(SMALL)checks SMALL bucket: [S1, S2]. Pops S2, marks it occupied.- Code "482931" generated. Assignment created: P1 -> S2, expires in 72 hours.
- State: available = {SMALL: [S1], MEDIUM: [M1], LARGE: [L1]}. Active assignments: {"482931": P1->S2}.
Step 2: Deliver a medium package.
- Package P2 (size=MEDIUM) arrives.
allocateLocker(MEDIUM)checks MEDIUM bucket: [M1]. Pops M1, marks it occupied.- Code "173649" generated. Assignment created: P2 -> M1.
- State: available = {SMALL: [S1], MEDIUM: [], LARGE: [L1]}.
Step 3: Deliver another medium package — but no MEDIUM lockers left.
- Package P3 (size=MEDIUM) arrives.
allocateLocker(MEDIUM)checks MEDIUM bucket: []. Empty. Tries LARGE bucket: [L1]. Pops L1.- Code "905217" generated. P3 -> L1.
- State: available = {SMALL: [S1], MEDIUM: [], LARGE: []}.
Step 4: Customer picks up P1.
- Customer enters code "482931" at LOC-1.
findAssignmentByCode("482931")returns P1->S2 assignment.- Not expired, not already picked up. Mark as picked up, release S2.
- State: available = {SMALL: [S1, S2], MEDIUM: [], LARGE: []}. "482931" removed from active assignments.
Step 5: P2 expires (customer never showed up).
runExpiryCheck()runs. P2->M1 assignment is expired.- Locker M1 released, code "173649" freed.
- In production, return-to-sender notification fires.
Complete Code Implementation
enum LockerSize {
SMALL = 1,
MEDIUM = 2,
LARGE = 3,
}
class Locker {
public isOccupied: boolean = false;
constructor(
public readonly lockerId: string,
public readonly size: LockerSize,
) {}
occupy(): void {
this.isOccupied = true;
}
release(): void {
this.isOccupied = false;
}
}
class Package {
constructor(
public readonly packageId: string,
public readonly size: LockerSize,
public readonly recipientId: string,
) {}
}
class LockerAssignment {
public pickedUp: boolean = false;
constructor(
public readonly package_: Package,
public readonly locker: Locker,
public readonly pickupCode: string,
public readonly deliveredAt: Date,
public readonly expiresAt: Date,
) {}
isExpired(now: Date): boolean {
return now > this.expiresAt && !this.pickedUp;
}
markPickedUp(): void {
this.pickedUp = true;
}
}
class CodeGenerator {
private usedCodes: Set<string> = new Set();
generate(length: number = 6): string {
while (true) {
let code = "";
for (let i = 0; i < length; i++) {
code += Math.floor(Math.random() * 10).toString();
}
if (!this.usedCodes.has(code)) {
this.usedCodes.add(code);
return code;
}
}
}
releaseCode(code: string): void {
this.usedCodes.delete(code);
}
}
class LockerLocation {
public readonly locationId: string;
public readonly address: string;
public readonly lockers: Locker[];
public activeAssignments: Map<string, LockerAssignment> = new Map();
private availableBySize: Map<LockerSize, Locker[]>;
constructor(locationId: string, address: string, lockers: Locker[]) {
this.locationId = locationId;
this.address = address;
this.lockers = lockers;
this.availableBySize = new Map<LockerSize, Locker[]>([
[LockerSize.SMALL, []],
[LockerSize.MEDIUM, []],
[LockerSize.LARGE, []],
]);
for (const locker of lockers) {
if (!locker.isOccupied) {
this.availableBySize.get(locker.size)!.push(locker);
}
}
}
allocateLocker(packageSize: LockerSize): Locker | null {
const sizes = [LockerSize.SMALL, LockerSize.MEDIUM, LockerSize.LARGE];
for (const size of sizes) {
if (size >= packageSize) {
const available = this.availableBySize.get(size)!;
if (available.length > 0) {
const locker = available.pop()!;
locker.occupy();
return locker;
}
}
}
return null;
}
releaseLocker(locker: Locker): void {
locker.release();
this.availableBySize.get(locker.size)!.push(locker);
}
addAssignment(assignment: LockerAssignment): void {
this.activeAssignments.set(assignment.pickupCode, assignment);
}
findAssignmentByCode(code: string): LockerAssignment | undefined {
return this.activeAssignments.get(code);
}
removeAssignment(code: string): void {
this.activeAssignments.delete(code);
}
cleanupExpired(now: Date): LockerAssignment[] {
const expired: LockerAssignment[] = [];
const codesToRemove: string[] = [];
for (const [code, assignment] of this.activeAssignments) {
if (assignment.isExpired(now)) {
expired.push(assignment);
codesToRemove.push(code);
this.releaseLocker(assignment.locker);
}
}
for (const code of codesToRemove) {
this.activeAssignments.delete(code);
}
return expired;
}
}
class LockerService {
public locations: Map<string, LockerLocation> = new Map();
private codeGenerator = new CodeGenerator();
private pickupWindowMs: number;
constructor(pickupWindowHours: number = 72) {
this.pickupWindowMs = pickupWindowHours * 60 * 60 * 1000;
}
addLocation(location: LockerLocation): void {
this.locations.set(location.locationId, location);
}
deliverPackage(locationId: string, pkg: Package): string {
const location = this.locations.get(locationId);
if (!location) {
throw new Error(`Unknown location: ${locationId}`);
}
const locker = location.allocateLocker(pkg.size);
if (!locker) {
throw new Error(
`No locker available at ${locationId} for ${LockerSize[pkg.size]}`
);
}
const now = new Date();
const code = this.codeGenerator.generate();
const assignment = new LockerAssignment(
pkg,
locker,
code,
now,
new Date(now.getTime() + this.pickupWindowMs),
);
location.addAssignment(assignment);
return code;
}
pickupPackage(locationId: string, code: string): Package {
const location = this.locations.get(locationId);
if (!location) {
throw new Error(`Unknown location: ${locationId}`);
}
const assignment = location.findAssignmentByCode(code);
if (!assignment) {
throw new Error("Invalid pickup code");
}
if (assignment.pickedUp) {
throw new Error("Package already picked up");
}
if (assignment.isExpired(new Date())) {
throw new Error("Pickup window expired — package returned to sender");
}
assignment.markPickedUp();
location.releaseLocker(assignment.locker);
location.removeAssignment(code);
this.codeGenerator.releaseCode(code);
return assignment.package_;
}
runExpiryCheck(): void {
const now = new Date();
for (const location of this.locations.values()) {
const expired = location.cleanupExpired(now);
for (const a of expired) {
this.codeGenerator.releaseCode(a.pickupCode);
}
}
}
}
// ---- Demo ----
const lockers = [
new Locker("S1", LockerSize.SMALL),
new Locker("S2", LockerSize.SMALL),
new Locker("M1", LockerSize.MEDIUM),
new Locker("L1", LockerSize.LARGE),
];
const loc = new LockerLocation("LOC-1", "123 Main St", lockers);
const service = new LockerService(72);
service.addLocation(loc);
const pkg1 = new Package("P1", LockerSize.SMALL, "user-alice");
const code1 = service.deliverPackage("LOC-1", pkg1);
console.log(`Package P1 delivered. Pickup code: ${code1}`);
const pkg2 = new Package("P2", LockerSize.MEDIUM, "user-bob");
const code2 = service.deliverPackage("LOC-1", pkg2);
console.log(`Package P2 delivered. Pickup code: ${code2}`);
const picked = service.pickupPackage("LOC-1", code1);
console.log(`Picked up: ${picked.packageId} for ${picked.recipientId}`);Extensibility
1. Adding Locker Size "Extra Large"
Add EXTRA_LARGE = 4 to the LockerSize enum. Because the allocation loop iterates over all sizes using the sizes array, it automatically considers the new size. No changes to LockerLocation or LockerService.
Tradeoff: If you have many sizes, the linear scan through sizes could slow down. For 4-5 sizes this is negligible. For dozens, you could use a sorted structure.
2. Multiple Pickup Attempts Tracking
Add an attempts: int field to LockerAssignment. In pickupPackage, if the code is wrong, increment a counter on the location. After N failed attempts from the same session, lock out further attempts for that location temporarily.
class LockerAssignment {
// ... existing fields ...
public failedAttempts: number = 0;
public readonly maxAttempts: number = 5;
recordFailedAttempt(): boolean {
this.failedAttempts += 1;
return this.failedAttempts >= this.maxAttempts;
}
}Tradeoff: Adds complexity to the pickup flow, but prevents brute-force code guessing.
3. Notification System (Observer Pattern)
If you need to send emails/SMS when a package is delivered or about to expire, add an observer interface:
interface LockerEventListener {
onPackageDelivered(assignment: LockerAssignment): void;
onPackagePickedUp(assignment: LockerAssignment): void;
onPackageExpired(assignment: LockerAssignment): void;
}
class EmailNotifier implements LockerEventListener {
onPackageDelivered(assignment: LockerAssignment): void {
console.log(
`Email to ${assignment.package_.recipientId}: ` +
`Your package is ready. Code: ${assignment.pickupCode}`
);
}
onPackagePickedUp(assignment: LockerAssignment): void {
// ...
}
onPackageExpired(assignment: LockerAssignment): void {
// ...
}
}The LockerService holds a list of listeners and calls them at the right moments. No changes to existing logic.
What is Expected at Each Level
| Level | Expectations |
|---|---|
| Junior | Identify the main entities (Locker, Package, Location). Write a basic allocate/pickup flow. May not handle concurrency or expiry. |
| Mid-level | Clean class separation, size-based allocation with "best fit" strategy, pickup code generation, expiry handling. Discuss thread safety even if not fully implemented. |
| Senior | Everything above plus: thread-safe allocation with locks, grouped-by-size data structure for O(1) allocation, Observer pattern for notifications, clear extensibility story, discuss trade-offs of lazy vs. active expiry cleanup, talk about how this would evolve into a distributed system. |