Car Rental LLD: Book the Count, Assign the Car at the Counter
A low-level design walkthrough of a car rental system: reservations against category capacity instead of specific cars, interval counting on the calendar, pickup-time assignment, and late-fee billing.
"Design a car rental system." If you've followed this queue, you arrive armed: the hotel taught date intervals, the airline taught the held-then-active lifecycle, the restaurant taught smallest-fit assignment. The rental counter is where those tools meet their final exam — plus the one twist that's genuinely its own: when you reserve "a compact for next week," no car on the lot is yours. Not at booking. Not the night before. A physical car gets your name only when keys cross the counter.
That twist — book the count, assign the car later — is the design. Get it and everything else is assembly; miss it and you'll spend the interview untangling why repainting one car cancels six reservations.
Let's start nowhere near a computer
A wedding caterer rents out dinner sets. When you book "60 plates for Saturday," she doesn't walk to the shelf and put your name on sixty specific plates — she checks her diary: how many plates are already promised for Saturday? 140 promised, 200 owned — your 60 fit. Which physical plates you get is decided Saturday morning by whoever loads the van, from whatever's clean.
Promise against the count; bind the identity at handover. The plates stay interchangeable until the last moment — and that's precisely why one chipped plate never breaks a booking. The hotel flirted with this (rooms within a type are fungible); the rental car commits to it completely, because cars leave the lot, come back late, and go into the shop without warning.
Where book-the-count runs
- Every rental counter — cars, tools, tuxedos, scaffolding: reserve a category, receive a unit.
- Cloud capacity — "reserve 8 vCPUs" never names the physical machine; binding happens at placement. Same design, humming in data centers.
- The hotel's overbooking follow-up — assign-at-check-in was the upgrade there; here it's the baseline.
Step 1 — Functional requirements (sentences first)
What the counter must do, as plain sentences — the functional requirements.
- A customer reserves a category (compact, SUV…) for a date range
[from, to). - The reservation holds if overlapping reservations for that category stay below the fleet count.
- At pickup, a free physical car of that category is assigned; the rental becomes active.
- At return, the bill is computed — late returns charge extra per day.
- Cancelling before pickup releases the capacity; cars are never involved.
That second sentence is a deliberate stance. The booking is a promise against a count, not a claim on a specific car — so two customers can hold "a compact next week" the instant there are two compacts, and a car going into the shop shrinks the count rather than orphaning a reservation. The half-open range [from, to) is the other stance: the return day is the next customer's pickup day, so it must not count as occupied — closed intervals are the hotel's checkout collision, on wheels.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for a rental counter they are the design:
- Correctness. A category is never oversold across overlapping date ranges; reserved-plus-active reconciles against fleet size on every day of the calendar. A double-booked car is the one bug a customer feels at the counter.
- Thread-safety. Two clerks reserving the last compact in the same second must not both succeed; the check-then-book is one atomic step or it is wrong.
- Performance. Availability for a category-and-date is a filtered count of overlapping reservations, not a scan of every car's calendar — the work scales with bookings in the window, not the fleet.
- Extensibility. New categories, pricing strategies, and insurance add-ons plug in without touching the reserve/pickup/return spine.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | availability is derived by counting overlapping reservations against fleet size — never a stored flag that can drift — Step 3 |
| Thread-safety | reserve is a single synchronized check-then-book; the count and the insert can't be split by a racing clerk — Steps 3, 7 |
| Performance | the half-open overlap filter counts only reservations touching the window, not per-car calendars — Step 3 |
| Extensibility | category is an enum, the rate lives in a lookup table, and the doubled late rate is one constant from a pricing Strategy — Steps 5, 7 |
Every trade-off below is chosen to keep one of these.
Step 3 — The capacity check: count overlaps, not cars
The hotel asked "which room has a calendar gap?" The rental asks something cheaper: "how many reservations of this category overlap mine?" Same half-open overlap formula, counted instead of matched:
public synchronized Reservation reserve(Category category, String customer,
LocalDate from, LocalDate to) {
if (!from.isBefore(to)) {
throw new IllegalArgumentException("pickup must be before return");
}
long overlapping = reservations.stream()
.filter(r -> r.category() == category && r.consumesCapacity())
.filter(r -> from.isBefore(r.to()) && r.from().isBefore(to)) // the formula
.count();
if (overlapping >= fleetCount(category)) {
throw new IllegalStateException("no " + category + " left for " + from + " → " + to);
}
Reservation r = new Reservation("R-" + nextId++, category, customer, from, to);
reservations.add(r);
return r;
}Note what's absent: no car loop, no per-car calendars. Capacity is derived — count promises against fleet size — the inventory article's "never store what you can derive," now on a calendar. Add a car to the fleet and capacity grows instantly; send one to the shop and you shrink the count, not surgery on bookings.
Which structure — and why. A reservation is a half-open interval [from, to) plus a category — not a pointer to a car — because that's what keeps cars fungible and the booking correct: two reservations on the same category are just two intervals to count, and a car in the shop changes only fleetCount, never a booking. Availability is a derived count over a List<Reservation>, not a stored available flag, because a flag is a second source of truth that drifts the moment a cancellation or a late return forgets to update it — counting can't drift. Car identity lives in a separate carsOut map (plate → reservation) that exists only between pickup and return, so the one place identity and booking touch is also the shortest-lived; that locality is what makes correctness easy to reason about. And money is integer minor units (paise as long), never double, so a bill is exact arithmetic, not a rounding argument.
Strictly, "max simultaneous overlap below capacity" is the precise condition (a sweep over interval endpoints); counting overlaps with your interval is the conservative approximation that can only refuse too eagerly, never oversell. Name the refinement, ship the safe version — that's the right order in an interview too.
Step 4 — Pickup: identity arrives with the keys
public synchronized Car pickup(String reservationId) {
Reservation r = reservationOrThrow(reservationId);
r.requireStatus(Status.RESERVED);
Car car = fleet.stream()
.filter(c -> c.category() == r.category())
.filter(c -> !carsOut.containsKey(c.plate())) // physically on the lot
.findFirst()
.orElseThrow(() -> new IllegalStateException("no " + r.category() + " on the lot"));
carsOut.put(car.plate(), r.id());
r.activate(car);
return car;
}The assignment is the restaurant's table search at its laziest: any free unit of the category will do, found at the last possible moment. The carsOut map — plate → reservation — is the only place car identity and booking ever touch, and it exists for exactly the duration of a rental.
Step 5 — Return: the bill, and the late edge
public synchronized long returnCar(String reservationId, LocalDate actualReturn) {
Reservation r = reservationOrThrow(reservationId);
r.requireStatus(Status.ACTIVE);
long rate = dailyRatePaise.get(r.category());
long bookedDays = ChronoUnit.DAYS.between(r.from(), r.to());
long lateDays = Math.max(0, ChronoUnit.DAYS.between(r.to(), actualReturn));
long bill = bookedDays * rate
+ lateDays * rate * 2; // late days at double rate — policy as math
carsOut.remove(r.assignedCar().plate()); // the car is anonymous again
r.complete(actualReturn);
return bill;
}Two notes the room rewards. The late return is priced, not forbidden — the car is already out; the design's job is making the consequence computable (and the doubled rate is one constant away from being a pricing Strategy). And on return the plate leaves carsOut — the car dissolves back into the anonymous pool, ready to be anyone's compact tomorrow.
There's a sharper edge hiding here, and naming it unprompted is the senior move: a late return can collide with the next reservation's pickup. The conservative fix is a buffer day between rentals (shrink capacity by maintenance gaps); the honest fix is operational — that's why real agencies overbook slightly and occasionally upgrade you for free. The model you built makes both expressible.
Step 6 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
| reserve a category, assign at pickup | bind a specific car at booking | a car breaking, returning late, or going to the shop never orphans a booking | correctness |
| availability derived by counting | a stored available flag per car | one source of truth that can't drift when a cancellation or late return is missed | correctness |
half-open interval [from, to) | closed [from, to] | the return day is the next customer's pickup day — no phantom one-day collision | correctness |
reserve is one synchronized step | check, then book in two steps | two clerks can't both pass the count for the last compact in the same instant | thread-safety |
| count only overlapping reservations | scan every car's calendar | work scales with bookings in the window, not the fleet size | performance |
money as integer paise (long) | double rupees | bills are exact arithmetic; the doubled late rate is a constant, not a rounding bug | extensibility |
The complete implementation
package dev.fiveyear.rental;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class RentalAgency {
public enum Category { COMPACT, SUV }
public enum Status { RESERVED, ACTIVE, RETURNED, CANCELLED }
public record Car(String plate, Category category) {}
public static final class Reservation {
private final String id;
private final Category category;
private final String customer;
private final LocalDate from;
private final LocalDate to;
private Status status = Status.RESERVED;
private Car assignedCar;
private LocalDate returnedOn;
Reservation(String id, Category category, String customer, LocalDate from, LocalDate to) {
this.id = id;
this.category = category;
this.customer = customer;
this.from = from;
this.to = to;
}
void activate(Car car) {
this.assignedCar = car;
this.status = Status.ACTIVE;
}
void complete(LocalDate actualReturn) {
this.returnedOn = actualReturn;
this.status = Status.RETURNED;
}
void cancel() {
requireStatus(Status.RESERVED);
this.status = Status.CANCELLED;
}
void requireStatus(Status expected) {
if (status != expected) {
throw new IllegalStateException("reservation is " + status + ", expected " + expected);
}
}
boolean consumesCapacity() {
return status == Status.RESERVED || status == Status.ACTIVE;
}
public String id() { return id; }
public Category category() { return category; }
public LocalDate from() { return from; }
public LocalDate to() { return to; }
public Status status() { return status; }
public Car assignedCar() { return assignedCar; }
}
private final List<Car> fleet = new ArrayList<>();
private final List<Reservation> reservations = new ArrayList<>();
private final Map<String, String> carsOut = new HashMap<>(); // plate → reservation id
private final Map<Category, Long> dailyRatePaise = new EnumMap<>(Category.class);
private int nextId = 1;
public void addCar(String plate, Category category) {
fleet.add(new Car(plate, category));
}
public void setDailyRate(Category category, long paise) {
dailyRatePaise.put(category, paise);
}
public synchronized Reservation reserve(Category category, String customer,
LocalDate from, LocalDate to) {
if (!from.isBefore(to)) {
throw new IllegalArgumentException("pickup must be before return");
}
long overlapping = reservations.stream()
.filter(r -> r.category() == category && r.consumesCapacity())
.filter(r -> from.isBefore(r.to()) && r.from().isBefore(to))
.count();
if (overlapping >= fleetCount(category)) {
throw new IllegalStateException("no " + category + " left for " + from + " → " + to);
}
Reservation r = new Reservation("R-" + nextId++, category, customer, from, to);
reservations.add(r);
return r;
}
public synchronized Car pickup(String reservationId) {
Reservation r = reservationOrThrow(reservationId);
r.requireStatus(Status.RESERVED);
Car car = fleet.stream()
.filter(c -> c.category() == r.category())
.filter(c -> !carsOut.containsKey(c.plate()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("no " + r.category() + " on the lot"));
carsOut.put(car.plate(), r.id());
r.activate(car);
return car;
}
public synchronized long returnCar(String reservationId, LocalDate actualReturn) {
Reservation r = reservationOrThrow(reservationId);
r.requireStatus(Status.ACTIVE);
long rate = dailyRatePaise.get(r.category());
long bookedDays = ChronoUnit.DAYS.between(r.from(), r.to());
long lateDays = Math.max(0, ChronoUnit.DAYS.between(r.to(), actualReturn));
long bill = bookedDays * rate + lateDays * rate * 2;
carsOut.remove(r.assignedCar().plate());
r.complete(actualReturn);
return bill;
}
public synchronized void cancel(String reservationId) {
reservationOrThrow(reservationId).cancel();
}
private long fleetCount(Category category) {
return fleet.stream().filter(c -> c.category() == category).count();
}
private Reservation reservationOrThrow(String id) {
return reservations.stream()
.filter(r -> r.id().equals(id))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("no reservation " + id));
}
}A week at the counter:
RentalAgency agency = new RentalAgency();
agency.addCar("KA-01-AA-1111", Category.COMPACT);
agency.addCar("KA-01-BB-2222", Category.COMPACT);
agency.setDailyRate(Category.COMPACT, 150_000); // ₹1,500/day
Reservation asha = agency.reserve(Category.COMPACT, "asha", d(10), d(14));
Reservation dev = agency.reserve(Category.COMPACT, "dev", d(12), d(15));
agency.reserve(Category.COMPACT, "meera", d(13), d(14)); // throws — both compacts promised
Car ashasCar = agency.pickup(asha.id()); // NOW a plate has her name
long bill = agency.returnCar(asha.id(), d(16)); // 2 days late:
// 4 × 1500 + 2 × 3000 = ₹12,000 — the lateness is math, not an argumentThe interview corner
Clarify before you code: One location or pickup-here-return-there? Does a reservation guarantee a category or a specific car (premium agencies sell both)? What's the late policy — fee, or grace then fee?
The follow-up ladder:
- "Why not assign the car at booking?" Because cars break, return late, and get repainted — binding early means every fleet hiccup cascades into customer calls. Bind at the counter; the pool absorbs chaos. This answer is the question.
- "Exact capacity, not the approximation." Sweep the interval endpoints of overlapping reservations and track the running maximum — refuse only if it would exceed fleet size. Twenty lines, and you named it before they did.
- "Multi-location with one-way rentals." Capacity becomes per-(category, location, day) and one-way rentals move future capacity between locations — suddenly it's flow balancing, which is why one-way fees exist. Sketch, don't build.
- "Maintenance." A car in the shop is an OUT_OF_SERVICE row that shrinks
fleetCountfor its interval — same overlap formula, applied to supply instead of demand. The symmetric reuse is the elegant answer. - "Two clerks, last compact, same second."
reserveis check-then-act behindsynchronized— the token bucket's race, at a counter. Per-location locks before anything fancier.
Mistakes that fail the round: per-car booking calendars (the cascading-cancellation design); closed intervals (the hotel's checkout collision, on wheels); billing from to instead of max(to, actual) — the free late week.
Where to go from here
Pocket version: promise the count, bind identity at handover, derive capacity from overlap counting, and price the late edge instead of arguing about it.
- Add the endpoint sweep for exact capacity and test it against the conservative version with property-style random bookings.
- Add pricing seasons and insurance add-ons — both are data tables joined at billing time.
- Next in the queue: the online book reader — from fleets and calendars to a quieter problem: remembering where ten thousand readers each stopped.
Sixty plates, Saturday, names assigned by whoever loads the van — the caterer had the architecture all along. The rental counter just added late fees.