bigonotes
CS ConceptsLLD

Package Locker System

Design self-service package lockers: best-fit locker assignment by size, one-time pickup codes, and freeing lockers on pickup.

4 min readLast updated LLD

A package locker system is a bank of self-service lockers where a courier drops off a parcel and the customer picks it up later with a code. The problem looks like a parking lot (allocate a container by size), but adds one-time access codes and a cleaner question about how to track which lockers are free.

Requirements#

Reframed: a delivered package is assigned to a locker; the customer gets a code to open it; the locker frees on pickup.

  1. The system has many lockers of different sizes. A package fits a locker if package size ≤ locker size.
  2. Deposit (courier): the courier drops a package into an assigned locker and the system returns a code.
  3. Pickup (customer): the customer enters the code, the locker opens, they take the package, and the locker becomes free.
  4. A code works only once.

Actors: delivery agent, customer.

Clarifications

  • Several locker sizes; prefer the smallest locker that fits so large lockers stay available.
  • One locker bank (multiple locations is an extension).
  • Failure cases: no fitting locker, invalid or reused code.

Out of scope: UI, concurrency, payment, notifications/app.

Core entities#

Four types model the whole system:

Entity Holds
Size (enum) SMALL, MEDIUM, LARGE (ordered)
Package id, size, description
Locker id, size, current package
LockerSystem free lockers bucketed by size, code → Locker map

Relationships#

The system owns the lockers; people only touch its two public methods:

From To Kind
LockerSystem Locker Composition: the system owns its lockers
Locker Package Association (0 or 1)
Courier, customer LockerSystem Use it through deposit and pickup

Key flows#

Deposit and pickup are mirror images: one takes a locker out of a free bucket, the other puts it back.

Deposit:

  1. For each size from smallest to largest: if the size is ≥ the package size and a free locker exists, pop it.
  2. Store the package in the locker.
  3. Generate a code.
  4. Map the code to the locker.
  5. Return the code. If nothing fits, return None.

Pickup:

  1. Look up the code. If it's missing, reject it (invalid or already used).
  2. Clear the locker.
  3. Delete the code.
  4. Push the locker back into its free bucket.

Tracking free lockers#

This design makes occupancy implicit: a free locker lives in its size bucket; an occupied one lives only in code_to_locker. There is no available flag to keep in sync.

Bucket membership (this design) available flag on each locker
Find any free locker of size ≥ X O(sizes)O(\text{sizes}) Scan lockers, or keep buckets anyway
"Is locker L3 free?" Not O(1)O(1) without an extra map O(1)O(1)
List all lockers Need a separate registry Easy
Risk of inconsistency One source of truth Flag and buckets can drift

Use buckets when the only question you ask is "give me a free locker that fits".

Code#

Free lockers live in size buckets; occupied ones live only in the code map:

import secrets
from enum import Enum
from typing import Dict, List, Optional


class Size(Enum):
    SMALL = 1
    MEDIUM = 2
    LARGE = 3


class Package:
    def __init__(self, package_id: str, size: Size, description: str):
        self.id = package_id
        self.size = size
        self.description = description


class Locker:
    def __init__(self, locker_id: str, size: Size):
        self.id = locker_id
        self.size = size
        self.package: Optional[Package] = None


class LockerSystem:
    def __init__(self):
        self.free_lockers: Dict[Size, List[Locker]] = {}
        self.code_to_locker: Dict[str, Locker] = {}

    def add_locker(self, locker: Locker) -> None:
        self.free_lockers.setdefault(locker.size, []).append(locker)

    def _generate_code(self) -> str:
        # secrets, not random: codes guard real packages
        while True:
            code = f"{secrets.randbelow(10**6):06d}"
            # Avoid colliding with a code that is still live
            if code not in self.code_to_locker:
                return code

    def deposit(self, package: Package) -> Optional[str]:
        locker = None
        # Try the smallest fitting size first
        for size in sorted(Size, key=lambda s: s.value):
            fits = size.value >= package.size.value
            if fits and self.free_lockers.get(size):
                locker = self.free_lockers[size].pop()
                break
        if locker is None:
            return None  # nothing fits right now

        locker.package = package
        code = self._generate_code()
        self.code_to_locker[code] = locker
        return code

    def pickup(self, code: str) -> Optional[Package]:
        # pop makes the code one-time
        locker = self.code_to_locker.pop(code, None)
        if locker is None:
            return None  # invalid or already used

        package, locker.package = locker.package, None
        self.free_lockers.setdefault(locker.size, []).append(locker)
        return package


if __name__ == "__main__":
    system = LockerSystem()
    system.add_locker(Locker("L1", Size.SMALL))
    system.add_locker(Locker("L2", Size.MEDIUM))

    # Takes L1
    code1 = system.deposit(Package("P1", Size.SMALL, "books"))
    # L1 is taken, so L2 (medium fits)
    code2 = system.deposit(Package("P2", Size.SMALL, "socks"))
    tv = Package("P3", Size.LARGE, "tv")
    print("no fit for large:", system.deposit(tv))        # None

    print("pickup:", system.pickup(code1).id)             # P1, L1 freed
    print("reuse code:", system.pickup(code1))            # None: one-time
    code3 = system.deposit(Package("P4", Size.SMALL, "pens"))
    print("L1 reused:", system.code_to_locker[code3].id)  # L1

Extensions and trade-offs#

Expect follow-ups on these:

  • Code expiry: store (locker, expires_at) and have a sweeper return expired packages to the courier and free the locker.
  • Multiple locations: a LockerLocation owns its lockers; a service picks a location near the customer, then a locker within it.
  • Assignment policy: wrap "smallest fitting first" in a LockerAssignmentStrategy so you can switch to, say, "balance wear across lockers".
  • Notifications: on deposit, publish an event that a notifier (SMS, email) subscribes to (Observer).
  • Concurrency: two couriers depositing at once could pop the same bucket; guard deposit/pickup with a lock per location.
  • Security: short numeric codes can be brute-forced; rate-limit attempts per locker bank.

Key takeaways#

  • Treat it as size-based allocation (like a parking lot) plus one-time codes.
  • Pick the smallest locker that fits to keep big lockers free.
  • Popping the code from the map both validates and invalidates it in one step.
  • Bucket membership as the single source of truth avoids flag drift, at the cost of per-locker lookups.
  • Generate codes with secrets and check for collisions.

Found a mistake? Tell me on LinkedIn ↗

Further reading#