CMD Guide
HomeOO & Low-Level DesignOO Design Problems

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:

diagram
diagram

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.

StepOperationChain after stepRunning cost(3)
1. Checkout basenew BaseRental(STANDARD, 60)Base60×3 = 180
2. Add insurancenew PerDayAddOn(chain, "Insurance", 15)Base → Ins180 + 45 = 225
3. Add GPSnew FlatFeeAddOn(chain, "GPS", 20)Base → Ins → GPS225 + 20 = 245
4. Return 2026-06-06lateDays = between(06-04, 06-06) = 2— compute fee —
5. Wrap late feenew FlatFeeAddOn(chain, "Late(2d)", 35×2)… → Late245 + 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."

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:

tAna's requestBen's requestC42.status
t0reads status = AVAILABLE ✓AVAILABLE
t1reads status = AVAILABLE ✓AVAILABLE
t2writes RESERVED (holder = Ana)RESERVED (Ana)
t3writes 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

Takeaways


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

Interviewer follow-ups & drills

  1. Why separate Vehicle, Reservation, Payment? Different change rates (fleet ops vs checkout vs finance).
  2. Failure: two reservations for same car same slot — need UNIQUE(vehicle,slot) or range exclusion + transaction.
  3. 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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes