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.
- The system has many lockers of different sizes. A package fits a locker if package size ≤ locker size.
- Deposit (courier): the courier drops a package into an assigned locker and the system returns a code.
- Pickup (customer): the customer enters the code, the locker opens, they take the package, and the locker becomes free.
- 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:
- For each size from smallest to largest: if the size is ≥ the package size and a free locker exists, pop it.
- Store the package in the locker.
- Generate a code.
- Map the code to the locker.
- Return the code. If nothing fits, return
None.
Pickup:
- Look up the code. If it's missing, reject it (invalid or already used).
- Clear the locker.
- Delete the code.
- 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 | Scan lockers, or keep buckets anyway | |
| "Is locker L3 free?" | Not without an extra map | |
| 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
LockerLocationowns its lockers; a service picks a location near the customer, then a locker within it. - Assignment policy: wrap "smallest fitting first" in a
LockerAssignmentStrategyso 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/pickupwith 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
secretsand check for collisions.