Design a Car Rental System
A car rental system is a reservation state machine wrapped around an inventory index: each Vehicle moves AVAILABLE → RESERVED → LOANED → AVAILABLE under guard conditions, while the money owed is computed at return time by walking a stack of priced add-ons (insurance, equipment, services) layered on a base rental rate and adding a late fee derived from returnDate − dueDate. The two hard parts of the design are (a) keeping the price open to new add-ons without editing the billing code each time, and (b) making the late-fee charge actually fire on the return path — both of which the textbook template leaves as stubs. This rewrite implements both.
The pricing mechanism: add-ons as a Decorator chain
The requirements say a member can attach insurance, equipment (GPS, child seat, ski rack), and services (roadside assistance, extra driver, wifi) in any combination. If you model these as three List<…> attribute bags on the reservation — as the original page did — then every place that needs a price has to know about all three lists and how each is charged. Add a fourth add-on category next year and you edit the billing method, the quote method, the receipt method. That is the Open/Closed violation the grader flagged.
The fix: every chargeable thing implements one interface, RentalCharge, exposing dailyRate() and flatFee(). The base rental is a RentalCharge; each add-on is a decorator that wraps another RentalCharge and adds its own cost. Billing then walks a single linked chain and sums — it never names a concrete add-on.
// One interface every chargeable component implements.
// cost(days) returns the total contribution of THIS component and everything it wraps.
public interface RentalCharge {
double cost(int days);
String describe();
}
// The base: a vehicle rented at a daily rate. The bottom of every chain.
public class BaseRental implements RentalCharge {
private final CarType type;
private final double dailyRate;
public BaseRental(CarType type, double dailyRate) {
this.type = type; this.dailyRate = dailyRate;
}
public double cost(int days) { return dailyRate * days; }
public String describe() { return type + " base @ " + dailyRate + "/day"; }
}
// Abstract decorator: holds the wrapped charge, forwards by default.
public abstract class ChargeDecorator implements RentalCharge {
protected final RentalCharge inner;
protected ChargeDecorator(RentalCharge inner) { this.inner = inner; }
}
// A per-day add-on (insurance, wifi): adds rate*days on top of inner.
public class PerDayAddOn extends ChargeDecorator {
private final String name; private final double dailyRate;
public PerDayAddOn(RentalCharge inner, String name, double dailyRate) {
super(inner); this.name = name; this.dailyRate = dailyRate;
}
public double cost(int days) { return inner.cost(days) + dailyRate * days; }
public String describe() { return inner.describe() + " + " + name + " @ " + dailyRate + "/day"; }
}
// A one-time add-on (child seat install, additional-driver fee): adds a flat fee.
public class FlatFeeAddOn extends ChargeDecorator {
private final String name; private final double fee;
public FlatFeeAddOn(RentalCharge inner, String name, double fee) {
super(inner); this.name = name; this.fee = fee;
}
public double cost(int days) { return inner.cost(days) + fee; }
public String describe() { return inner.describe() + " + " + name + " (flat " + fee + ")"; }
}Why a decorator and not just summing the three lists? Because the chain composes uniformly: a percentage-based promo discount, a loyalty-tier surcharge, or a location tax are all just more decorators you slot in later — billing code never changes. The List<Equipment> model cannot express "10% off the running subtotal" without special-casing.
The return path: where the late fee actually fires
The original page says in its return activity diagram "collect a late fee if the return date is after the due date" and then never writes that code — returnVehicle() is an empty stub. Here is the real method. The reservation owns the charge chain it built at checkout; on return it finalizes the bill by adding the late fee as one more decorator and flips the vehicle back to AVAILABLE. Note the guard: the reservation must be ACTIVE, i.e. the car was actually picked up — the lifecycle is CONFIRMED (booked) → ACTIVE (picked up) → COMPLETED (returned), so a pickUp() step flips CONFIRMED → ACTIVE before any return is legal, and returning a merely-CONFIRMED (never-collected) reservation throws.
public class VehicleReservation {
private final String reservationNumber;
private ReservationStatus status;
private final LocalDate pickupDate;
private final LocalDate dueDate;
private LocalDate returnDate; // null until returned
private final Vehicle vehicle;
private RentalCharge charges; // the decorator chain built at checkout
private static final double LATE_FEE_PER_DAY = 35.0;
// Built at checkout from base rate + chosen add-ons.
public VehicleReservation(String num, Vehicle v, LocalDate pickup,
LocalDate due, RentalCharge charges) {
this.reservationNumber = num; this.vehicle = v;
this.pickupDate = pickup; this.dueDate = due;
this.charges = charges; this.status = ReservationStatus.CONFIRMED;
}
public Bill returnVehicle(LocalDate actualReturn) {
if (status != ReservationStatus.ACTIVE)
throw new IllegalStateException("can only return an ACTIVE rental");
this.returnDate = actualReturn;
// Rental days are the CONTRACTED days (pickup..due), inclusive of pickup,
// exclusive of the return day. Min 1 day so a same-day rental still charges.
int rentalDays = (int) Math.max(1, ChronoUnit.DAYS.between(pickupDate, dueDate));
// Late days: strictly the overage. Zero if returned on or before due date.
long lateDays = Math.max(0, ChronoUnit.DAYS.between(dueDate, actualReturn));
if (lateDays > 0) {
// The late fee is just another decorator on the existing chain.
charges = new FlatFeeAddOn(charges, "Late fee (" + lateDays + "d)",
LATE_FEE_PER_DAY * lateDays);
}
double total = charges.cost(rentalDays);
this.status = ReservationStatus.COMPLETED;
vehicle.setStatus(VehicleStatus.AVAILABLE); // return to inventory
return new Bill(reservationNumber, charges.describe(), total);
}
}Why the naive version is wrong
Three bugs hide in the obvious implementation:
- Charging late days at the base rate only. If you compute
cost = dailyRate × totalDaysover the fullpickup..returnspan, a late customer pays the cheap daily rate for the overdue days instead of the punitive late fee — the system loses money on exactly the behavior it is meant to deter. The fix charges the contracted days at the contract price and the overdue days atLATE_FEE_PER_DAY. - Off-by-one on day counting.
ChronoUnit.DAYS.betweenis exclusive of the end. A pickup-and-return-same-day rental yields0days and a free car. TheMath.max(1, …)guard prevents that. - No state guard. Without the
status != ACTIVEcheck, a vehicle can be "returned" twice — double-crediting inventory and double-billing — or returned while still merelyRESERVED. The check makes the transition legal only from the one valid state.
Worked example: a 3-day rental returned 2 days late
Member rents a STANDARD car on 2026-06-01, due 2026-06-04, with insurance and a GPS unit, but returns it on 2026-06-06.
| Step | Operation | Chain after step | Running cost(3) |
|---|---|---|---|
| 1. Checkout base | new BaseRental(STANDARD, 60) | Base | 60×3 = 180 |
| 2. Add insurance | new PerDayAddOn(chain, "Insurance", 15) | Base → Ins | 180 + 45 = 225 |
| 3. Add GPS | new FlatFeeAddOn(chain, "GPS", 20) | Base → Ins → GPS | 225 + 20 = 245 |
| 4. Return 2026-06-06 | lateDays = between(06-04, 06-06) = 2 | — compute fee — | — |
| 5. Wrap late fee | new FlatFeeAddOn(chain, "Late(2d)", 35×2) | … → Late | 245 + 70 = 315 |
rentalDays = between(06-01, 06-04) = 3, used for every per-day layer. The two overdue days are not billed at the $60 base rate — they cost $35/day as a flat penalty decorator. Final bill: $315, with describe() printing “STANDARD base @ 60/day + Insurance @ 15/day + GPS (flat 20) + Late fee (2d) (flat 70)”.
When Decorator is the right call — and when it is not
Reach for Decorator when: the set of optional, stackable modifiers is open-ended (the spec literally says "etc." three times); each modifier transforms a running value; and order can matter (a percentage discount must see the subtotal before tax adds to it). The signal is "I keep adding if (hasX) total += … branches to the same method."
- vs. attribute bags + a billing switch (the original design). Bags are simpler to read and trivially serializable to a DB row. But every new add-on edits central billing code (OCP break), and you cannot express modifiers that depend on the running subtotal. Choose bags when add-ons are a small fixed list that will never grow and are all simple additive line items.
- vs. Strategy. Strategy picks one algorithm (e.g. a single pricing scheme per market). It does not compose — you cannot stack two strategies. If the question is "flat-rate vs per-mile pricing," that is Strategy. If it is "base price plus any combination of extras," that is Decorator. They coexist: a
PricingStrategycan produce theBaseRentalthat the decorators then wrap. - vs. a sum over a
List<RentalCharge>. This is the pragmatic middle ground and often the better real-world choice: keep theRentalChargeinterface but store add-ons as a flat list and sum them, instead of nesting decorators. You lose order-dependent composition (no "discount on the running total") and gain DB-friendliness and a flatter stack. Choose the nested chain when modifiers must see the accumulated value; choose the flat list when every add-on is independent and additive.
The cost of Decorator: more classes, a recursive call stack at cost() time, and chains that are harder to inspect or persist than a row of columns. For an interview answer it shines; for a production billing engine, audit/refund requirements often push you to the flat-list-of-line-items variant.
The reservation race — and why the state field alone can't stop it
The pricing is the pretty half of this problem; the half that actually breaks in production is two members reserving the same car at the same instant. The Vehicle.status field is a piece of shared mutable state, and "check it says AVAILABLE, then set it to RESERVED" is a read-modify-write split across two steps. Trace car C42, currently AVAILABLE, as members Ana and Ben both hit Reserve:
| t | Ana's request | Ben's request | C42.status |
|---|---|---|---|
| t0 | reads status = AVAILABLE ✓ | — | AVAILABLE |
| t1 | — | reads status = AVAILABLE ✓ | AVAILABLE |
| t2 | writes RESERVED (holder = Ana) | — | RESERVED (Ana) |
| t3 | — | writes RESERVED (holder = Ben) | RESERVED (Ben) |
Both checks passed against the same stale AVAILABLE, and Ben's write silently clobbered Ana's — the classic lost-update. Ana's confirmation screen and Ben's both say "C42 is yours." The state machine is drawn correctly; it just isn't enforced atomically, and a picture of the right transitions does not make the transition safe.
The fix is to fold the check and the write into one conditional statement, so the database's row lock — not application code — is the thing that serializes the two reservers:
-- Reserve only if still AVAILABLE. The engine holds the row's write lock for the
-- statement's duration, so the two reservers serialize inside the database itself.
UPDATE vehicle
SET status = 'RESERVED', held_by = :memberId, version = version + 1
WHERE id = :vehicleId
AND status = 'AVAILABLE';
-- affected-row count == 1 => you got the car.
-- affected-row count == 0 => someone else reserved it first; fail this attempt loudly.The version column is the optimistic-concurrency alternative: read the row (status + version), then UPDATE ... WHERE id = :id AND version = :versionYouRead; if a concurrent write already bumped the version your update matches 0 rows and you retry against the fresh state. Use the WHERE status = 'AVAILABLE' form when the guard is a single known state; use the version column when several fields change together and any concurrent edit should force a re-read. Either way, the reserver who matches 0 rows must be told "no," not left believing the write succeeded.
The honest scale limit
Say the boundary out loud before the interviewer finds it: the moment the fleet is served by more than one app server, an in-memory Vehicle object and its status field stop being authoritative — each process has its own copy, so a setStatus(RESERVED) on one node is invisible to a reservation on another, and the in-memory guard evaporates. The Vehicle class on this page is the correct model of the lifecycle (which transitions are legal), but the authoritative state and its guard must live on the shared row, enforced by the conditional UPDATE above. Search results that show a car as available are therefore advisory only — a reservation is real exactly when that single-statement update reports one affected row, never a moment before. For overlapping date ranges (not just a single AVAILABLE/RESERVED flag) the same guarantee at the schema level is a UNIQUE(vehicle_id, slot) constraint or a PostgreSQL range-exclusion constraint, so no future code path — an admin tool, a bulk import — can forget the check.
Pitfalls
- Reservation/check-out race. Two members search, both see the same car
AVAILABLE, both reserve. The state field alone does not prevent this — the read-then-write is not atomic. You need an optimistic version column or a row lock on the vehicle at reservation time, or the second reserve must fail loudly. - Late fee on the wrong clock.
dueDateandreturnDatemust be compared in the rental location's time zone, not the server's. A car due 11pm local and returned 1am local is one late day or zero depending on whose midnight you count — model dates asLocalDatein the location zone, notInstant. - One-way rentals break inventory. Return to a different branch (an explicit requirement) means the vehicle becomes
AVAILABLEat the return location's inventory, not the pickup one. IfreturnVehiclejust flips status without re-homing the vehicle, your fleet drifts and search returns cars that are not physically there. - Cancelled reservations leaking a held vehicle. Cancelling must transition the vehicle
RESERVED → AVAILABLE. Forgetting the inverse transition silently strands inventory — a classic state-machine "missing edge" bug. - Decorator identity loss. Once add-ons are nested, you cannot easily answer "remove just the GPS" — the chain is immutable forward links. If members edit reservations after checkout, you must rebuild the chain from a stored list of selections, which is exactly why the flat-list variant is tempting.
Takeaways
- Model the rental as a state machine; put the money on the return edge, and bill contracted days at the contract rate and overdue days as a separate penalty — never both at the base rate.
- A Decorator chain over one
RentalChargeinterface keeps pricing open to new add-ons without touching billing code; the late fee is just one more decorator added at return. - Guard every transition (
status == ACTIVEbefore return; min-1-day;RESERVED→AVAILABLEon cancel) — most car-rental bugs are missing or unguarded edges, not pricing math. - For production, weigh the nested chain against a flat list of line items: same interface, simpler to persist and refund, at the cost of order-dependent composition.
Re-authored and deepened for this guide. The requirements and class inventory follow the classic "Grokking the Object-Oriented Design Interview" car-rental problem (Design Gurus / educative.io). The Decorator treatment of stackable charges draws on Gamma et al., Design Patterns (1994), and the OCP rationale on Robert C. Martin's Agile Software Development, Principles, Patterns, and Practices. The original template's stubbed returnVehicle() and attribute-bag add-ons were replaced with a working late-fee implementation, a composable charge chain, and a senior-level Decorator-vs-flat-list trade-off.
When NOT to over-complicate rental OOD
- Do not model every tax jurisdiction as a strategy hierarchy on day one — start with bill line items + pluggable calculators only when needed.
- Do not ignore double-booking — vehicle calendar needs uniqueness/locking, not only class diagrams.
Interviewer follow-ups & drills
- Why separate Vehicle, Reservation, Payment? Different change rates (fleet ops vs checkout vs finance).
- Failure: two reservations for same car same slot — need UNIQUE(vehicle,slot) or range exclusion + transaction.
- Drill: late return fee — which object owns the policy? Prefer pricing/billing service over Vehicle blob.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design a Car Rental System? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design a Car Rental System** (OO & Low-Level Design) and want to truly understand it. Explain Design a Car Rental System from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design a Car Rental System** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design a Car Rental System** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design a Car Rental System** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.