Amazon Locker
Understanding the Problem​
Amazon Locker is a self-service package pickup system. A delivery driver deposits a package into an available compartment, the system generates an access token, and the customer uses that code to retrieve their package. The code expires after a set time period, freeing up the compartment if the package isn't collected.
Amazon Locker
Requirements​
You walk into the interview and get greeted with this prompt:
That's a start, but not enough detail for us to begin coding. Before touching a whiteboard, spend 3-5 minutes asking questions to nail down exactly what you're building.
The goal here is to expose edge cases and constraints before they bite you halfway through implementation. In a real project, this is the conversation you'd have with your PM or tech lead before writing a single line of code.
Structure your questions around four areas: what are the core operations, what can go wrong, what's the scope boundary, and what might we need to extend later?
If you haven't used an Amazon locker, say so and ask for a quick primer:
Good. We've learned that size matters and allocation is strict. That's simpler than dealing with fallback logic.
Perfect. This cuts out a bunch of complexity. We're not designing a logistics system. Just the locker itself.
That's a clean boundary. Our system generates the code. Notification lives upstream.
We've just scoped out a feature that would add state tracking and time windows. Good to identify it, but we're not building it today.
So access tokens map 1:1 with packages. No shared codes or bulk pickups.
Now we know the TTL and what happens when it expires. The compartment becomes available again.
Straightforward. No queueing, no reservations. Either there is space or there isn't.
Final Requirements​
After that back-and-forth, you'd summarize this on the whiteboard:
Requirements:
1. Carrier deposits a package by specifying size (small, medium, large)
- System assigns an available compartment of matching size
- Returns compartment number and access token, or error if no space
2. Upon successful deposit, an access token is generated and returned
- One access token per package
3. User retrieves package by entering access token
- System validates code and returns compartment number
- Throws specific error if code is invalid or expired
4. Access tokens expire after 7 days
- Expired packages automatically free up the compartment
5. Invalid access tokens are rejected with clear error messages
- Wrong code, already used, or expired - user gets specific feedback
Out of scope:
- How the package gets to the locker (delivery logistics)
- How the access token reaches the customer (SMS/email notification)
- Lockout after failed access token attempts
- UI/rendering layer
- Multiple locker stations
- Payment or pricing
Notice we called out two tradeoffs explicitly: lockout logic and access token delivery. In an interview, it's smart to mention features you considered and chose not to build. It shows you're thinking ahead without over-engineering. You can always circle back to these in the extensibility section.
Core Entities and Relationships​
With clear requirements in hand, the next step is figuring out what objects make up the system. Your instinct should be to look for nouns in the requirements and turn them into classes. But keep in mind that not every noun deserves to be an entity. Some are just data. Some are concepts that belong as fields on other classes.
Let's walk through the candidates and see which ones actually pull their weight.
Package - This one seems obvious at first. We're storing packages, so we need a Package class, right? But stop and think about what a Package would actually do in our system. It would hold... what? A size? That's really a property of the compartment it occupies. An expiration date? That's tied to the access token, not the physical package. A tracking number? We don't use it for anything. Package doesn't have behavior. It's just a bag of fields that belong elsewhere.
Compartment - This is a real thing. A physical container with a size and an ID. Clear entity.
Locker - Someone needs to orchestrate the whole system. When a driver says "I have a medium package," something needs to scan compartments, find an available one, generate a code, and tie it all together. That's the Locker. It's the entry point.
AccessToken - At first, you might think the access token is just a string field on Package or Compartment. But an AccessToken isn't just a code. It's a bearer token with an expiration time. It represents the right to open a specific compartment . That's a concept worth modeling. If AccessToken is its own entity, it can own the expiration logic and the mapping to the compartment.
With these considerations, our final entity set is:
Locker - The orchestrator. Owns all compartments, the AccessToken lookup map, and an occupancy set tracking which compartments are in use. Handles deposit and pickup operations.
AccessToken - Represents a bearer token for compartment access. Holds the code, expiration timestamp, and a reference to the compartment it unlocks. Enforces expiry when validating.
Compartment - A physical locker slot. Has an ID and a size. No knowledge of access tokens — it's just a dumb container.
Most candidates instinctively add a Package class here. At first glance it feels obvious, we’re storing packages, so we should model them. But once you trace the actual behavior in this system, Package doesn’t do anything. The system never operates on a package directly. It doesn’t move, change state, or make decisions. Size belongs to the compartment. Expiration belongs to the access permission, not the physical object. And lookup during pickup is driven entirely by the access token, not by a package identifier. Adding Package would just create an extra indirection layer that you bounce through to answer simple questions like “is this code valid and which door should open?” Instead, we model the two concepts that actually carry behavior: Compartment for the physical container, and AccessToken for time-bound access. This keeps lookups direct, responsibilities clean, and extension (like adding security rules or attempt tracking) easy without contaminating unrelated classes.
You might not nail the entity design on your first try. That's fine. Start with what seems obvious, then refine as you work through the class design. If you notice awkward indirection or classes with no real behavior, come back and adjust. Design is iterative.
Class Design​
Now that we've settled on three entities, we need to define their interfaces. What state does each one hold, and what methods does it expose? We'll start with the orchestrator and work our way down. Since Locker is the entry point for the system, we'll design it first, then move on to AccessToken and Compartment.
For each class, we'll trace back to the requirements and ask two questions: what does this entity need to remember, and what operations does it need to support?
Locker​
The Locker is the system's public API. External code interacts with it to deposit packages and pick them up, so everything flows through this class.
From the requirements, we can derive the state:
This gives us:
class Locker:
- compartments: Compartment[]
- accessTokenMapping: Map<string, AccessToken>
- occupiedCompartments: Set<string>
Why use a map for access token lookup? Technically, we don't need it. A typical locker has maybe 50-100 compartments, so scanning a list would be fast enough— O(n) with n < 100 is perfectly fine. But using a map is a reasonable choice for a few reasons: it shows you're thinking about lookup patterns, it keeps the design clean (access token codes directly map to AccessToken objects), and it's how you'd build this if the system ever scaled to support multiple locker stations. In an interview, mention both options. We're choosing the map because it makes the lookup intent explicit and the performance characteristics predictable, but a simple list scan would work just fine at this scale and we'd appreciate a candidate calling that out.
Next, the operations. Every public method should map to a concrete user action from the requirements:
Putting it together:
class Locker:
- compartments: Compartment[]
- accessTokenMapping: Map<string, AccessToken>
- occupiedCompartments: Set<string>
+ new Locker(compartments: Compartment[]) -> Locker
+ depositPackage(size: Size) -> { compartmentId: string, tokenCode: string } | error
+ pickup(tokenCode: string) -> string | error
A few design choices worth calling out:
Why return an object from depositPackage ? We need to return both the compartment ID (so the driver knows where to put the package) and the access token code (so it can be sent to the customer). Returning a structured object is clearer than multiple return values or out parameters.
Why does pickup throw instead of returning null? Customers need clear feedback about why their pickup failed. If their code is expired, they need to know so they can contact support. If it's invalid, they know to double-check the code. Throwing errors with specific messages provides better UX than silently returning null. This also keeps the behavior consistent with depositPackage , which throws when there's no available compartment.
AccessToken​
AccessToken represents a bearer token for compartment access. It needs to hold the code itself, know when it expires, and point to the compartment it unlocks.
From the requirements:
State:
class AccessToken:
- code: string
- expiration: timestamp
- compartment: Compartment
For operations, AccessToken only needs one public method. The key behavior is validation with expiry enforcement:
class AccessToken:
- code: string
- expiration: timestamp
- compartment: Compartment
+ new AccessToken(code: string, expiration: timestamp, compartment: Compartment) -> AccessToken
+ getCompartmentIfValid() -> Compartment | null
+ getCompartment() -> Compartment
+ getCode() -> string
Why getCompartmentIfValid() instead of separate methods? This encapsulates the validation logic. The AccessToken knows its expiration, so the AccessToken should enforce it. Callers shouldn't have to remember to check isExpired() before calling getCompartment() . Bundle the check with the access. This is Information Expert in action—the entity that owns the data makes the decision.
Why return null instead of throwing an exception? This is an intentional design choice. getCompartmentIfValid() is a query method that the caller uses to check validity. Returning null lets the caller decide how to handle expiry—whether to throw an error, log it, clean up state, or take some other action. If AccessToken threw an exception here, it would force specific error handling on all callers. By returning null, we give Locker the flexibility to handle expiry differently in different contexts (e.g., during pickup vs. during background cleanup). This follows the principle that exceptions should be for exceptional conditions, not for expected validation failures.
We also expose getCode() so Locker can return it to the caller during deposit.
Compartment​
Compartment is the simplest entity. It represents a physical locker slot. It needs an ID, a size, and a way to track whether it's occupied.
From the requirements:
State:
class Compartment:
- id: string
- size: Size
Compartment is intentionally simple. It doesn't track occupancy—that's the Locker's job via the occupancy set.
For operations:
class Compartment:
- id: string
- size: Size
+ new Compartment(id: string, size: Size) -> Compartment
+ getSize() -> Size
+ getId() -> string
Compartment is now a pure data holder. No state management, no occupancy tracking. It just represents the physical properties of a locker slot.
The Size enum:
enum Size:
SMALL
MEDIUM
LARGE
That's the complete class design. Three entities, each with a focused responsibility: Locker orchestrates workflows and tracks occupancy, AccessToken enforces access control with expiry, and Compartment represents physical constraints only. Clean separation of concerns .
Implementation​
With the class design locked in, we need to implement the actual method bodies. Before diving in, check with your interviewer—some want working code, others prefer pseudocode, and some just want you to talk through the logic. We'll use pseudocode here since it's the most common, but we'll include full implementations in multiple languages at the end.
For each method, we'll follow this pattern:
- Define the core logic - The happy path that fulfills the requirement
- Handle edge cases - Invalid inputs, boundary conditions, unexpected states
Interviewers usually focus on the most interesting methods. For our locker system, those are:
depositPackage- shows the allocation logic and how we tie together compartments and access tokenspickup- shows the validation flow and cleanupgetCompartmentIfValid- shows how AccessToken enforces expiry
Locker​
Let's start with depositPackage , which is the core workflow.
Core logic:
- Find an available compartment of the requested size
- Generate an access token for that compartment
- Mark the compartment as occupied in the occupancy set
- Store the access token in the lookup map
- Return both the compartment ID and access token code
Edge cases:
- No compartment available of the requested size
- Invalid size parameter
Here's the pseudocode:
{ compartmentId, tokenCode } depositPackage(Size size) {
Compartment compartment = getAvailableCompartment(size)
if (compartment == null) {
throw Error("No available compartment of size " + size)
}
AccessToken accessToken = generateAccessToken(compartment)
occupiedCompartments.add(compartment.getId())
accessTokenMapping.put(accessToken.getCode(), accessToken)
return {
compartmentId: compartment.getId(),
tokenCode: accessToken.getCode()
}
}
The flow is straightforward. We find a compartment, create the access token, mark it as occupied, and return the information the driver needs. Notice we're not checking if size is valid—that would happen in getAvailableCompartment when we scan for matching compartments.
Now pickup :
Core logic:
- Look up the access token by code
- Ask the access token for its compartment (with validity check)
- If valid, clean up the deposit and return the compartment ID
- If invalid (expired or doesn't exist), throw a specific error
Edge cases:
- Access token doesn't exist in the map
- Access token exists but is expired
- Access token code is null or empty
string pickup(string tokenCode) {
if (tokenCode == null || tokenCode.isEmpty()) {
throw Error("Invalid access token code")
}
AccessToken accessToken = accessTokenMapping.get(tokenCode)
if (accessToken == null) {
throw Error("Invalid access token code")
}
Compartment compartment = accessToken.getCompartmentIfValid()
if (compartment == null) {
// Access token expired - clean it up and inform the user
clearDeposit(accessToken)
throw Error("Access token has expired")
}
// Valid pickup - clean up and return compartment
string compartmentId = compartment.getId()
clearDeposit(accessToken)
return compartmentId
}
We call getCompartmentIfValid() which handles the expiry check internally. If it returns null, we know the access token is expired, so we clean it up and throw an error telling the user their code expired. If it returns a compartment, we clean up the deposit and return the ID. The cleanup happens in both the success and expiry cases because we're freeing up the compartment either way.
Notice we throw "Invalid access token code" for both codes that never existed and codes that were already used. Once you pick up a package, we remove the access token from accessTokenMapping , so a second attempt with the same code looks identical to typing in a random code that was never generated.
If you wanted to distinguish "already used" from "never existed", you'd need to track used codes separately—maybe a usedTokens set or an isUsed flag on the AccessToken before removing it. But that adds state management for marginal UX benefit. In most systems, "Invalid access token code" is sufficient feedback for both cases. The user just needs to know their code doesn't work.
We do give specific feedback for expired codes ("Access token has expired") because that's actionable—the user knows to contact support for a package that's still sitting in the locker.
The helper methods are simpler:
Compartment getAvailableCompartment(Size size) {
for (Compartment c : compartments) {
if (c.getSize() == size && !occupiedCompartments.contains(c.getId())) {
return c
}
}
return null
}
Just a linear scan. We check if the compartment's size matches and if it's not in the occupancy set. With fewer than 100 compartments, this is plenty fast.
AccessToken generateAccessToken(Compartment compartment) {
string code = generateRandomCode() // 6-digit number, UUID, whatever
timestamp expiration = now() + 7.days()
return new AccessToken(code, expiration, compartment)
}
We're hand-waving the actual code generation. In a real system, you'd use a cryptographically secure random generator. The key thing is setting the expiration to 7 days from now.
void clearDeposit(AccessToken accessToken) {
Compartment compartment = accessToken.getCompartment()
occupiedCompartments.remove(compartment.getId())
accessTokenMapping.remove(accessToken.getCode())
}
This is where we clean up all the state tracking. We remove the compartment from the occupancy set and remove the access token from the map. If we forget one of these steps, we'd have inconsistent state—either a compartment appearing occupied when it's actually free, or an access token in the map for a compartment that shows as available.
Notice that clearDeposit calls getCompartment() unconditionally, without checking validity. This is safe because we only call clearDeposit from two places: after a successful pickup (where we already validated), or after detecting expiry in pickup (where we still need to clean up). In both cases, the AccessToken-to-Compartment reference exists, even if the time-based validity check fails. The compartment object itself is still there, it's just the access permission that expired.
AccessToken​
The key method here is getCompartmentIfValid :
Core logic:
- Check if current time is before expiration
- If yes, return the compartment
- If no, return null
Edge cases:
- Expiration timestamp is in the past
Compartment getCompartmentIfValid() {
if (now() < expiration) {
return compartment
}
return null
}
Compartment getCompartment() {
return compartment
}
The simplicity is the point. AccessToken owns the expiration logic, so it makes the decision. Callers don't need to know how validity is determined.
We expose both getCompartmentIfValid() and getCompartment() because they serve different purposes. When validating user access during pickup, we want the expiry check bundled in. When cleaning up internal state in clearDeposit , we need the compartment reference regardless of expiry—the compartment object still exists even if the access permission expired.
Compartment​
The methods here are just simple getters:
Size getSize() {
return size
}
string getId() {
return id
}
Compartment has no behavior, just data. All the logic lives in Locker and AccessToken.
That's the complete implementation. The logic is simple because we've done the hard work upfront in design. Each class has one clear job, and methods are short and focused.
Verification​
Let's trace through a deposit and pickup to verify the state management and cleanup logic work correctly. This helps catch issues before they become bugs.
Locker has compartments A (SMALL), B (MEDIUM), C (LARGE), all available. AccessToken map and occupancy set are empty.
Deposit a medium package:
Initial: compartments=[A, B, C], accessTokenMapping={}, occupiedCompartments={}
getAvailableCompartment(MEDIUM) → Compartment B (size matches, not in occupancy set)
generateAccessToken(B) → AccessToken("ABC123", expiration=now+7days, compartment=B)
occupiedCompartments.add("B") → set now contains B
accessTokenMapping.put("ABC123", accessToken) → map now has entry
Result: { compartmentId: "B", tokenCode: "ABC123" }
State: compartments=[A, B, C], accessTokenMapping={"ABC123" → AccessToken}, occupiedCompartments={"B"}
Notice how Locker tracks occupancy separately from the Compartment objects themselves.
Successful pickup:
accessTokenMapping.get("ABC123") → AccessToken exists
accessToken.getCompartmentIfValid() → now < expiration, returns B
clearDeposit(accessToken):
- occupiedCompartments.remove("B") → B now available again
- accessTokenMapping.remove("ABC123")
Result: "B"
State: compartments=[A, B, C], accessTokenMapping={}, occupiedCompartments={}
Both tracking structures cleaned up.
Expired pickup (8 days later):
accessTokenMapping.get("ABC123") → AccessToken still exists (lazy cleanup)
accessToken.getCompartmentIfValid() → now > expiration, returns null
clearDeposit(accessToken):
- occupiedCompartments.remove("B") → B now available again
- accessTokenMapping.remove("ABC123")
throw Error("Access token has expired")
Result: Error thrown
State: compartments=[A, B, C], accessTokenMapping={}, occupiedCompartments={}
The system self-heals on first use. Expired access tokens stay in the map until accessed, which is fine at this scale.
Notice that expired access tokens aren't removed immediately when they expire. The compartment stays occupied until someone tries to use the expired code or a valid pickup happens. This is called lazy cleanup—we only clean up when we encounter the expired access token during normal operations.
In a production system, you'd want proactive cleanup too. A background job would periodically scan the accessTokenMapping for expired entries and call clearDeposit to free up compartments. But for this interview problem, lazy cleanup keeps the design simple while still satisfying the core requirement that expired codes can't be used.
If your interviewer asks "what happens if nobody ever tries to pick up an expired package?", this is your cue to discuss background jobs (see the extensibility section below).
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Set
import random
class DepositResult:
def __init__(self, compartment_id: str, token_code: str):
self.compartment_id = compartment_id
self.token_code = token_code
class Locker:
def __init__(self, compartments: List["Compartment"]):
self.compartments = compartments
self.access_token_mapping: Dict[str, "AccessToken"] = {}
self.occupied_compartments: Set[str] = set()
def deposit_package(self, size: "Size") -> DepositResult:
compartment = self._get_available_compartment(size)
if compartment is None:
raise Exception(f"No available compartment of size {size}")
access_token = self._generate_access_token(compartment)
self.occupied_compartments.add(compartment.get_id())
self.access_token_mapping[access_token.get_code()] = access_token
return DepositResult(compartment.get_id(), access_token.get_code())
def pickup(self, token_code: str) -> str:
if not token_code:
raise Exception("Invalid access token code")
access_token = self.access_token_mapping.get(token_code)
if access_token is None:
raise Exception("Invalid access token code")
compartment = access_token.get_compartment_if_valid()
if compartment is None:
self._clear_deposit(access_token)
raise Exception("Access token has expired")
compartment_id = compartment.get_id()
self._clear_deposit(access_token)
return compartment_id
def _get_available_compartment(self, size: "Size") -> Optional["Compartment"]:
for c in self.compartments:
if c.get_size() == size and c.get_id() not in self.occupied_compartments:
return c
return None
def _generate_access_token(self, compartment: "Compartment") -> "AccessToken":
code = f"{random.randint(0, 999999):06d}"
expiration = datetime.now() + timedelta(days=7)
return AccessToken(code, expiration, compartment)
def _clear_deposit(self, access_token: 'AccessToken') -> None:
compartment = access_token.get_compartment()
self.occupied_compartments.discard(compartment.get_id())
self.access_token_mapping.pop(access_token.get_code(), None)
Extensibility​
If time allows, interviewers will sometimes add small twists to test whether our design can evolve cleanly. You typically won't need to fully implement these changes. Just explain how the classes would adapt. The depth and quantity of the extensibility follow-ups correlate with the candidate's target level (junior, mid-level, senior). Junior candidates often won't get any, mid-level may get one or two, and senior candidates may be asked to go into more depth.
If you're a junior engineer, feel free to skip this section and stop reading here! Only carry on if you're curious about the more advanced concepts.
Below are the most common ones for locker systems, with more detail than you'd need in an actual interview.
1. "How would you handle size fallback?"​
Right now if all medium compartments are full, we reject the deposit even if large compartments are available. What if we want to allow a smaller package to use a larger compartment as a fallback?
Compartment getAvailableCompartment(Size requestedSize) {
// Try exact size first
for (Compartment c : compartments) {
if (c.getSize() == requestedSize && !occupiedCompartments.contains(c.getId())) {
return c
}
}
// Fall back to larger sizes
Size[] fallbackSizes = getLargerSizes(requestedSize) // e.g., MEDIUM → [LARGE]
for (Size fallbackSize : fallbackSizes) {
for (Compartment c : compartments) {
if (c.getSize() == fallbackSize && !occupiedCompartments.contains(c.getId())) {
return c
}
}
}
return null // No compartment available
}
Size[] getLargerSizes(Size size) {
if (size == SMALL) return [MEDIUM, LARGE]
if (size == MEDIUM) return [LARGE]
return [] // LARGE has no fallback
}
This keeps the rest of the design unchanged. The allocation logic is encapsulated in one method.
2. "What if a package is never picked up? How do you free the compartment?"​
Right now we use lazy cleanup—compartments are only freed when someone attempts pickup with an expired code. But if a customer never tries to pick up, the compartment stays occupied forever.
class LockerMaintenance:
- locker: Locker
void cleanupExpiredAccessTokens() {
List<AccessToken> expiredTokens = []
// Scan all access tokens for expired ones
for (AccessToken token : locker.accessTokenMapping.values()) {
if (token.getCompartmentIfValid() == null) { // Already expired
expiredTokens.add(token)
}
}
// Clean up all expired access tokens
for (AccessToken token : expiredTokens) {
locker.clearDeposit(token)
}
}
// Scheduled job (pseudocode)
every 1.hour:
maintenance.cleanupExpiredAccessTokens()
Notice we collect all expired access tokens first, then clean them up in a separate pass. If we tried to modify accessTokenMapping while iterating over it, we'd get concurrent modification errors. This two-pass approach is a common pattern when you need to mutate a collection you're currently iterating.
This design requires making clearDeposit visible to LockerMaintenance , which means it can't stay private to Locker. You could make it package-private if both classes live in the same package, or add a public cleanupExpired() method on Locker that does the same thing but keeps the implementation detail hidden.
The cleanup frequency is a tradeoff between compartment availability and CPU usage. Running it more often means compartments free up faster, but you're burning cycles checking access tokens that probably haven't expired yet. Once per hour is usually fine for a physical locker system—expired packages aren't blocking new deposits in real time, they're just occupying space that could be used.
What is Expected at Each Level?​
Ok so what am I looking for at each level?
Junior​
At the junior level, I'm primarily looking for whether you can break down the problem into sensible classes and implement the basic operations. You should identify the core entities (Locker, Compartment, AccessToken) and understand their relationships. The deposit and pickup flows should work correctly for the happy path. I don't expect you to nail every edge case on the first pass, but you should demonstrate awareness that edge cases exist. If you can implement a working solution where packages go into compartments, access tokens are generated, and customers can retrieve packages with valid codes, you're in good shape. Getting stuck on the entity design is fine as long as you can talk through your reasoning and adjust when I give hints.
Mid-level​
For mid-level candidates, I expect cleaner design decisions with less hand-holding. You should recognize that Package isn't a useful entity and arrive at the three-class model (Locker, AccessToken, Compartment) through reasoning, not just guessing. The separation of concerns should be clear: Locker orchestrates and tracks state, AccessToken handles access control and expiration, Compartment is a simple data holder. Your implementation should handle the key edge cases: invalid access token codes, expired codes, full compartments. You should be able to explain why you made specific design choices, like why the occupancy set lives on Locker rather than having Compartment track its own state. If time allows, you should be able to discuss at least one extensibility scenario without needing detailed guidance.
Senior​
Senior candidates should drive the design conversation with minimal prompting. I expect you to quickly identify that Package doesn't pull its weight and propose a clean three-entity model with clear justification. The design should demonstrate Information Expert - AccessToken enforces expiry because it owns the expiration timestamp, Locker tracks occupancy because it orchestrates all state changes. You should proactively discuss tradeoffs: why centralize state management in Locker rather than distribute it, lazy cleanup vs. proactive background jobs, and when you'd introduce a Package entity (multiple packages per compartment scenario). Your code should be clean and testable. I also expect you to anticipate interviewer questions, like bringing up expiration handling, size fallback strategies, or multi-package scenarios before I ask. Strong senior candidates often finish with time to discuss how the design would need to change for a multi-location locker network or integration with a notification service.