Course Content
Object-Oriented Design Interview
14 sections · 29 lessons
Shipping Locker: lifecycles, allocation, access codes and extensions
The familiar parts of this design — sizes, fitting, claiming a free compartment — carry straight over from the parking lot. This lesson is about the parts that do not: two interlocking lifecycles, some of whose transitions are driven only by a clock, a drop-off that takes two steps, and access codes that have to be secure as well as correct.
The parcel and locker lifecycle
Two lifecycles that interlock: the locker's, which repeats forever, and the parcel's, which runs once. Drawing both and showing where they touch is the point of this step.
Locker states
| State | Meaning | Can be allocated? |
|---|---|---|
FREE | Empty and available | Yes |
RESERVED | Allocated to a courier who has not yet dropped off | No |
OCCUPIED | Holds a parcel awaiting collection | No |
EXPIRED | Holds a parcel past its deadline, awaiting reclaim | No |
OUT_OF_SERVICE | Broken lock, jammed door, or under maintenance | No |
The RESERVED state is the one candidates leave out, and it matters. There is a real gap — seconds to a minute — between the system saying "use locker A14" and the courier physically closing the door. Without a reserved state, a second courier can be sent to the same locker during that gap. This is the same claim-do-not-peek idea as the parking lot spot assignment, made visible as a state.
Parcel states
IN_TRANSIT → AWAITING_PICKUP → COLLECTED, with a branch to EXPIRED → RETURNED.
Why two machines rather than one
The temptation is to give the locker a status that covers everything and drop the parcel lifecycle. It fails for a concrete reason: a parcel exists before it reaches a locker and after it leaves one. Its state must be answerable when no locker is involved — "where is my parcel?" is a question about the parcel, not about a compartment.
Keeping them separate means each machine has a clean set of states, and the interaction is three well-defined moments rather than a fog of combined statuses.
The transitions driven by time
Two transitions have no actor: the reservation timeout (a courier who never arrives) and the pickup deadline. Both need the same mechanism decision as the movie booking seat hold — a periodic sweep, a lazy evaluation on read, or both.
Recommend both, for the same reason: lazy evaluation keeps correctness (an expired code can never validate, even if the sweeper is down), and the sweep keeps the availability index honest so a free locker is actually offered to the next courier.
Allocation and access, in code
Two methods: allocating a locker at drop-off, and validating a code at pickup.
Allocation behind a Strategy
The rule today is "smallest locker that fits". Requirement N4 said it must be replaceable, so it is an interface — and the justification is the one from Abstraction:
"Allocation is the thing the network operator will want to tune. Smallest-fitting is right for capacity, but an accessibility rule — never put a parcel above shoulder height for a recipient who needs it low — is a business decision that will arrive later. One interface, one class per rule."
1public interface AllocationStrategy {2 Optional<Locker> allocate(LockerBank bank, ParcelSize size);3}45public class SmallestFittingStrategy implements AllocationStrategy {6 public Optional<Locker> allocate(LockerBank bank, ParcelSize size) {7 for (LockerSize candidate : LockerSize.fittingSizes(size)) { // smallest first8 Optional<Locker> locker = bank.claimFree(candidate); // claim, do not peek9 if (locker.isPresent()) return locker;10 }11 return Optional.empty();12 }13}claimFree removes the locker from the free queue as it returns it — the same claim-do-not-peek move as the parking lot spot assignment, and it is again what removes most of the race before any lock exists.
Drop-off, end to end
1public Reservation dropOff(Parcel parcel, String bankId, Courier courier) {2 LockerBank bank = bankRepository.find(bankId);3 Locker locker = strategy.allocate(bank, parcel.getSize())4 .orElseThrow(() -> new NoLockerAvailableException(bankId, parcel.getSize()));56 locker.reserve(courier, clock.now().plus(RESERVATION_WINDOW)); // FREE -> RESERVED7 Reservation reservation = new Reservation(8 idGenerator.next(), parcel, locker,9 clock.now(), clock.now().plus(rules.pickupWindow())); // e.g. 3 days1011 lockerController.open(locker.getId()); // hardware12 reservationRepository.save(reservation);13 return reservation;14}1516public void confirmDeposit(String reservationId) { // door closed17 Reservation reservation = reservationRepository.find(reservationId);18 reservation.getLocker().occupy(reservation); // RESERVED -> OCCUPIED19 reservation.getParcel().markAwaitingPickup();20 AccessCode code = accessCodeService.issueFor(reservation);21 notifier.notifyRecipient(reservation, code);22}Two things to narrate:
- Drop-off is two steps, not one. Reserve and open; then, when the door sensor reports closed, occupy and notify. If the courier walks away without depositing, the reservation times out and the locker returns to FREE — the dashed transition in the lifecycle diagram above.
- The notification happens after the door closes, never before. A recipient who receives a code for an empty locker will arrive and find nothing.
Access codes: generation, storage, validation
1public class AccessCodeService {2 private static final int CODE_LENGTH = 6;3 private final SecureRandom random = new SecureRandom();45 public AccessCode issueFor(Reservation reservation) {6 reservation.invalidateExistingCodes(); // resends invalidate the old code7 String value = randomDigits(CODE_LENGTH);8 AccessCode code = new AccessCode(hash(value), reservation.getExpiresAt());9 reservation.addCode(code);10 return code.withPlaintextForDelivery(value); // plaintext exists only here11 }1213 public boolean validate(String bankId, String attempt) {14 Optional<Reservation> match = reservationRepository15 .findActiveByBankAndCodeHash(bankId, hash(attempt));16 if (match.isEmpty()) { rateLimiter.recordFailure(bankId); return false; }1718 Reservation reservation = match.get();19 if (!reservation.getActiveCode().validate(attempt, clock.now())) return false;20 reservation.getActiveCode().markUsed(); // single use, enforced here21 return true;22 }23}Four decisions worth saying out loud, and each is one line of code:
SecureRandom, notRandom. A predictable code is a parcel someone else collects. This is a one-word change with a real security consequence, and interviewers notice it.- Store a hash, not the code. Anyone reading the database can otherwise open every locker in the network.
- Single-use is enforced in
validate, at the moment of success, not by the caller. Behaviour with the data it needs (Phase 4: assigning responsibilities). - Rate-limit failed attempts per bank. A six-digit code is one in a million, but a keypad accepting unlimited guesses reduces that to patience. Three failures then a lockout window is the standard shape.
A six-digit code is a reasonable choice to defend out loud: short enough to type on a keypad in the rain, and with rate limiting, sufficient. Without rate limiting it would not be.
Extensions
1. Returns — the reverse flow
A customer returns an item: they book a slot, get a code, put the parcel in, and a courier collects it later. The states are the same and the actors swap:
| Delivery | Return | |
|---|---|---|
| Who deposits | Courier | Customer |
| Who collects | Recipient | Courier |
| Who needs a code | Recipient (to open) | Customer (to open and deposit) |
| Locker states | FREE → RESERVED → OCCUPIED → FREE | Identical |
The design point: this is not a new state machine, it is the same one with different actors. If your model has CourierDropOff and RecipientPickup as classes, this extension is painful; if it has Reservation with a depositor and a collector, it is a configuration change. Say which your design is — and if it is the first, say what you would change.
2. Refrigerated and oversized lockers
The same problem as electric-vehicle charging spots in the parking lot extensions, and the same answer: features, not subclasses.
1public class Locker {2 private final LockerSize size;3 private final Set<LockerFeature> features; // REFRIGERATED, GROUND_LEVEL, EXTRA_SECURE4}5public class Parcel {6 private final Set<LockerFeature> required; // empty for most parcels7}Allocation gains one filter: the locker must fit the size and carry every required feature. A RefrigeratedLocker extends Locker gets you stuck the moment a parcel needs refrigerated and ground-level.
Naming the transfer explicitly — "this is the same shape as electric-vehicle spots in the parking lot problem" — is worth saying. It is evidence of the transfer skill this section opened with.
3. Capacity planning across a location
If a location is full of small lockers and every parcel is medium, the network operator is losing money. Two ways to attack it:
- Reporting: a rejected drop-off is a data point. Record every
NoLockerAvailableExceptionwith the size requested and the time, and the demand mix falls out of the log. This is one line of code and the highest-value thing you can offer. - Adjustable lockers: some real networks have removable dividers, so two small lockers can merge into one medium. That makes size mutable, which breaks the assumption that a locker's size is final — mention it as a design consequence rather than implementing it.
4. Notification retries
The recipient's message may fail — a wrong number, a full inbox, a network problem. Three things follow:
- Notification is an interface with implementations per channel (message, email, app push), which is the polymorphism example from Polymorphism nearly verbatim.
- Retry with backoff, and fall back to another channel after N failures.
- The deadline does not start until the notification succeeds. This is the good detail: if a recipient is never told, the three-day clock counting down is unfair. Either start the clock on successful delivery of the notification, or extend the deadline when notification is late. Raising this unprompted is a strong move because it is a policy question hiding in a technical one.
5. The recipient who never collects
The complete lifecycle, and worth walking through because it is the question that closes this problem:
- Day 3: parcel expires. Locker moves to
EXPIRED, parcel toEXPIRED. The code stops validating. - The recipient is notified that collection has failed.
- A courier is dispatched to reclaim. They authenticate as a courier — a different credential path from a recipient code — open the locker, and take the parcel.
- Locker returns to
FREE. Parcel moves toRETURNED_TO_SENDER. - The locker is not free before step 3. This is the part people get wrong: expiry does not free the compartment, because the parcel is physically still inside it. Only a human removing it does.
That last sentence is the one to say. It is a small piece of physical reasoning that a purely software model misses, and it is exactly the kind of detail that distinguishes a candidate who thought about the machine from one who thought about the diagram.