Low Level Design

Design an Amazon Locker System

A complete low-level design walkthrough for Amazon Locker — from requirements clarification to token-based access, compartment management, and expiry enforcement in Java.

September 18, 2026·12 min read

Problem Description#

Design an Amazon Locker system where delivery drivers can deposit packages into physical compartments and customers can retrieve them using a one-time access code.

At its core, this is a resource allocation and access-control problem:

  • Compartments have fixed sizes (SMALL, MEDIUM, LARGE)
  • Drivers are assigned a matching compartment on deposit and receive a token
  • Customers retrieve their package by presenting the token within 7 days
  • Expired packages remain locked until staff physically clears them

This is a great LLD problem because it requires thinking about entity ownership, token lifecycle, and when to put state on the entity vs. the orchestrator.


Clarify Requirements#

Before designing anything, ask these questions in an interview:

Functional

  • Are there different compartment sizes? Do we allow fallback to a larger one if the exact size isn't available?
  • How does the customer receive the access code — is notification in scope?
  • What happens when a customer enters a wrong code multiple times?
  • Are access tokens single-use? Do they expire?
  • What happens to a package when its token expires?
  • Can a staff member open expired compartments in bulk?

Non-functional

  • How many concurrent deposit/pickup operations are expected?
  • Should the system support multiple locker stations?
  • Does the locker need to interact with hardware (sensors, actuators)?

Final Requirements#

After clarification, here's what we'll build:

  • Carrier deposits a package by specifying size — system assigns a matching compartment, opens it, and returns a 6-digit access token
  • No fallback to larger compartments; returns an error if no exact match is available
  • Customer retrieves a package by entering their access token — system validates, opens, and frees the compartment
  • Tokens expire after 7 days; expired tokens are rejected with a clear error
  • Expired packages stay in their compartment until staff runs the manual clearance operation
  • Invalid tokens (wrong, already used, expired) return distinct error messages

Core Entities#

EntityResponsibility
LockerSingleton orchestrator — owns all compartments and the token lookup map. Entry point for deposit, pickup, and staff clearance.
AccessTokenBearer token for compartment access. Holds the code, expiration timestamp, and a reference to the compartment it unlocks. Owns expiry logic.
CompartmentA physical locker slot. Tracks its own size and occupancy status.
CompartmentSizeEnum — SMALL, MEDIUM, LARGE.
CompartmentStatusEnum — AVAILABLE, OCCUPIED, OUT_OF_SERVICE.

Why no Package entity? The only package attribute this system cares about is its size — which is just an input parameter. All other package data (customer info, shipping ID) belongs to the external fulfillment system.


Patterns Used#

1. Singleton — Locker#

Only one Locker instance should manage the compartments at a given physical location. A synchronized getInstance() prevents duplicate allocation under concurrent requests and ensures a single source of truth for the accessTokenMap.

2. State Object — CompartmentStatus#

Using an enum (AVAILABLE, OCCUPIED, OUT_OF_SERVICE) instead of a plain boolean occupied flag makes the compartment lifecycle extensible. Adding maintenance mode or a reserved state requires no structural change — just a new enum value.

3. First-class Value Object — AccessToken#

The token isn't a string field bolted onto Compartment. It's its own entity that owns expiry logic (isExpired()) and carries a reference back to its compartment. This keeps expiry checks out of Locker and makes the model easy to extend (renewal, one-time-use enforcement, audit logging).


Code#

Enums — Size and Status#

The two enumerations that govern what a compartment is and whether it's available.

java
public enum CompartmentSize {
    SMALL,
    MEDIUM,
    LARGE
}

Compartment#

A physical locker slot. Owns its size and status; open() simulates the hardware unlock signal.

java
public class Compartment {
    public final CompartmentSize compartmentSize;
    public CompartmentStatus status;

    public Compartment(String size) {
        this.compartmentSize = parseSize(size);
        this.status = CompartmentStatus.AVAILABLE;
    }

    private CompartmentSize parseSize(String size) {
        return switch (size.toUpperCase()) {
            case "SMALL"  -> CompartmentSize.SMALL;
            case "MEDIUM" -> CompartmentSize.MEDIUM;
            case "LARGE"  -> CompartmentSize.LARGE;
            default -> throw new IllegalArgumentException("Invalid compartment size: " + size);
        };
    }

    public String getCompartmentSize() { return compartmentSize.name(); }

    public boolean isAvailable() { return status == CompartmentStatus.AVAILABLE; }

    public void markOccupied()      { this.status = CompartmentStatus.OCCUPIED; }
    public void markFree()          { this.status = CompartmentStatus.AVAILABLE; }
    public void markOutOfService()  { this.status = CompartmentStatus.OUT_OF_SERVICE; }

    public void open() {
        System.out.println("Compartment [" + compartmentSize + "] opened.");
    }
}

AccessToken#

A bearer token that carries the compartment reference and enforces its own expiry — keeping that logic out of the orchestrator.

java
import java.time.Instant;

public class AccessToken {
    public final String code;
    public final Instant expirationTime;
    public final Compartment compartment;

    public AccessToken(String code, Compartment compartment, Instant expirationTime) {
        this.code           = code;
        this.compartment    = compartment;
        this.expirationTime = expirationTime;
    }

    public String getCode()             { return code; }
    public Compartment getCompartment() { return compartment; }

    public boolean isExpired() {
        return !Instant.now().isBefore(expirationTime);
    }
}

Locker — Singleton Orchestrator#

Owns all compartments and the token map. Handles deposit, pickup, and staff-initiated clearance of expired slots.

java
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;

public class Locker {
    private static Locker instance;
    private final List<Compartment> compartments;
    private final Map<String, AccessToken> accessTokenMap;

    private Locker(List<Compartment> compartmentList) {
        this.compartments   = compartmentList;
        this.accessTokenMap = new HashMap<>();
    }

    public static synchronized Locker getInstance(List<Compartment> compartmentList) {
        if (instance == null) {
            instance = new Locker(compartmentList);
        }
        return instance;
    }

    public String depositPackage(String size) {
        Compartment compartment = findAvailableCompartment(size);
        if (compartment == null) {
            throw new IllegalStateException("No available compartment of size: " + size);
        }

        compartment.open();
        compartment.markOccupied();
        AccessToken token = generateAccessToken(compartment);
        accessTokenMap.put(token.getCode(), token);

        return token.getCode();
    }

    public void retrievePackage(String tokenCode) {
        if (tokenCode == null || tokenCode.isEmpty()) {
            throw new IllegalArgumentException("Token code cannot be null or empty");
        }

        AccessToken token = accessTokenMap.get(tokenCode);
        if (token == null) {
            throw new IllegalArgumentException("Invalid access token");
        }
        if (token.isExpired()) {
            throw new IllegalStateException("Access token has expired");
        }

        token.getCompartment().open();
        clearDeposit(token);
    }

    public void openExpiredCompartments() {
        List<AccessToken> expired = accessTokenMap.values().stream()
                .filter(AccessToken::isExpired)
                .toList();

        for (AccessToken token : expired) {
            token.getCompartment().open();
            clearDeposit(token);
        }
    }

    private Compartment findAvailableCompartment(String size) {
        for (Compartment compartment : compartments) {
            if (compartment.getCompartmentSize().equals(size) && compartment.isAvailable()) {
                return compartment;
            }
        }
        return null;
    }

    private AccessToken generateAccessToken(Compartment compartment) {
        String code = String.format("%06d", new Random().nextInt(1_000_000));
        Instant expiry = Instant.now().plus(7, ChronoUnit.DAYS);
        return new AccessToken(code, compartment, expiry);
    }

    private void clearDeposit(AccessToken token) {
        token.getCompartment().markFree();
        accessTokenMap.remove(token.getCode());
    }
}

Demo#

java
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;

public class LockerDemo {
    public static void main(String[] args) {
        List<Compartment> compartments = List.of(
                new Compartment("SMALL"),
                new Compartment("MEDIUM"),
                new Compartment("LARGE")
        );

        Locker locker = Locker.getInstance(compartments);

        // Scenario 1: Successful deposit
        System.out.println("=== Deposit MEDIUM package ===");
        String token = locker.depositPackage("MEDIUM");
        System.out.println("Token issued: " + token);

        // Scenario 2: Successful retrieval
        System.out.println("\n=== Retrieve with valid token ===");
        locker.retrievePackage(token);
        System.out.println("Package retrieved successfully.");

        // Scenario 3: Expired token
        System.out.println("\n=== Retrieve with expired token ===");
        Compartment c = compartments.get(2);
        c.markOccupied();
        AccessToken expiredToken = new AccessToken(
                "EXPIRED", c, Instant.now().minus(1, ChronoUnit.DAYS));
        locker.accessTokenMap.put("EXPIRED", expiredToken);

        try {
            locker.retrievePackage("EXPIRED");
        } catch (IllegalStateException e) {
            System.out.println("Error: " + e.getMessage()); // Access token has expired
        }

        // Scenario 4: Staff clears expired compartments
        System.out.println("\n=== Staff: open expired compartments ===");
        locker.openExpiredCompartments();
    }
}

Class Diagram#


Extendible — Follow Ups#

1. Lockout after failed attempts#

Add a Map<String, Integer> failedAttempts to Locker. Increment on each invalid token; throw a LockoutException after a configurable threshold. Reset the count on successful retrieval.

2. SMS / email notification#

depositPackage already returns the token code. Wire a downstream NotificationService that accepts the code and customer contact details — no changes to Locker required.

3. Multiple locker stations#

Remove the static singleton and introduce a LockerNetwork that routes deposit requests to the nearest station with a matching available compartment. Each Locker remains independent.

4. Fallback to a larger compartment#

Replace the strict size match in findAvailableCompartment with a best-fit strategy: iterate sizes [SMALL → MEDIUM → LARGE] starting at the requested size and return the first available slot. The problem notes this is intentionally excluded — surface it as a trade-off in the interview.

5. Two-phase deposit with sensor confirmation#

Split depositPackage into openForDeposit() (opens the compartment) and confirmDeposit() (generates and returns the token only after the driver signals that the package is physically inside). This prevents issuing tokens for compartments that were never actually loaded.

6. Token renewal#

Add a renewToken(String oldCode, int extraDays) method to Locker. It looks up the existing token, validates it isn't already expired, creates a replacement with an extended expiry, swaps the map entry, and returns the new code. The compartment stays occupied throughout.