Skip to content

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

  1. Multiple locker locations, each with a configurable mix of Small, Medium, and Large lockers.
  2. Size-based allocation — assign the smallest available locker that fits the package.
  3. Delivery flow — delivery agent scans package, system allocates locker, agent places package.
  4. Pickup flow — customer enters code, system validates, locker opens.
  5. Timeout and return — if not picked up within N hours, mark for return and free locker.
  6. Unique pickup codes per delivery.
  7. 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

EntityResponsibility
LockerSizeEnum defining locker dimensions (SMALL, MEDIUM, LARGE)
LockerRepresents a single physical locker — knows its size and whether it is occupied
LockerLocationA physical site containing multiple lockers — manages allocation and deallocation
PackageRepresents a package with size info — the thing being stored
PickupCodeA unique code tied to a specific package-locker assignment
LockerAssignmentLinks a package to a locker with timestamps and pickup code
LockerServiceOrchestrates the delivery and pickup flows across locations
CodeGeneratorGenerates 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 24x18x15

We define an ordering so SMALL < MEDIUM < LARGE, which allows the "smallest fit" allocation logic.

Locker

State:

  • lockerId: string
  • size: LockerSize
  • isOccupied: boolean

Methods:

  • occupy() — marks locker as occupied
  • release() — 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: string
  • size: LockerSize — the minimum locker size needed
  • recipientId: string

LockerAssignment

State:

  • package: Package
  • locker: Locker
  • pickupCode: string
  • deliveredAt: Date
  • expiresAt: Date
  • pickedUp: boolean

Methods:

  • isExpired(now) — checks if the pickup window has passed
  • markPickedUp() — flags as collected

LockerLocation

State:

  • locationId: string
  • address: string
  • lockers: Locker[]
  • activeAssignments: Map<string, LockerAssignment> — keyed by pickup code
  • lock: Mutex

Methods:

  • allocateLocker(packageSize) -> Locker — finds smallest available locker that fits
  • releaseLocker(locker) — frees a locker
  • findAssignmentByCode(code) -> LockerAssignment
  • cleanupExpired(now) — scans for expired assignments

LockerService

Methods:

  • deliverPackage(locationId, package) -> PickupCode — orchestrates delivery
  • pickupPackage(locationId, code) -> Package — orchestrates pickup
  • runExpiryCheck() — periodic cleanup

Final Class Design

typescript
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. Locker manages its own state. LockerLocation manages allocation across lockers. LockerService orchestrates 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: LockerAssignment composes Package and Locker rather 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.
typescript
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

typescript
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)

typescript
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

  1. All lockers fullallocateLocker returns null, service raises an error. The delivery agent must try another location.
  2. Expired code used for pickup — Caught in pickupPackage before opening the locker.
  3. Same code entered at wrong location — Codes are per-location, so the lookup fails.
  4. 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.
  5. Package larger than any locker — No locker with size >= packageSize exists, allocation returns null.

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

typescript
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.

typescript
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:

typescript
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

LevelExpectations
JuniorIdentify the main entities (Locker, Package, Location). Write a basic allocate/pickup flow. May not handle concurrency or expiry.
Mid-levelClean class separation, size-based allocation with "best fit" strategy, pickup code generation, expiry handling. Discuss thread safety even if not fully implemented.
SeniorEverything 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.

Frontend interview preparation reference.