Design a car rental system
Vehicles, locations, reservations and pricing with late returns.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Strategy — Swapping an algorithm at runtime by passing an object.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Intervals and scheduling — Meetings, bookings and conflicts as interval arithmetic.
01The design, in the six-step framework
1. Requirements and use cases
Car rental is a reservation problem with a twist at the end: the return. Late returns, damage and add-ons change the bill, so pricing must be computed at return time from what actually happened, not at booking time from what was planned.
- A customer searches a branch for vehicles of a category (economy, SUV, van) available between a pickup and a return time.
- A customer reserves a category at a branch for the window, optionally with add-ons (child seat, GPS, insurance).
- Staff hands over a specific vehicle at pickup and records the odometer and fuel.
- Staff takes the return, records time, odometer, fuel and damage, and the system bills the customer including late fees.
- A customer cancels before pickup.
Out of scope: one-way rentals between branches, payments, maintenance scheduling. Constraints: availability per category per branch over a time window must be fast; pricing rules will change often (weekend rates, long-rental discounts), so they live outside the reservation; a vehicle cannot be handed to two customers.
2. Core entities and relationships
Vehicle: plate, category, branch, status (available, rented, maintenance).Branch: owns vehicles and anAvailabilityledger per category.Reservation: customer, branch, category, window, add-ons, status; a vehicle is attached at pickup.Rental: the live part of a reservation after pickup: vehicle, pickup record, return record.PricingPolicy: computes the charge from the reservation and the return record; one strategy per rate card.AddOn: a priced extra, applied per day or per rental.RentalService: the facade.
A reservation composes its rental (the rental is the reservation's own history), and the rental aggregates the vehicle, which belongs to the branch. The pricing policy is aggregated: it is configuration that outlives any service instance.
3. Interfaces and key methods
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional, Protocol
class Category(Enum):
ECONOMY = auto()
SUV = auto()
VAN = auto()
class ResStatus(Enum):
RESERVED = auto()
ACTIVE = auto()
RETURNED = auto()
CANCELLED = auto()
@dataclass(frozen=True)
class Window:
start: int # unix seconds
end: int
def days(self) -> int:
"""Billable days, rounded up, at least one."""
return max(1, -(-(self.end - self.start) // 86400))
@dataclass(frozen=True)
class AddOn:
name: str
per_day: int = 0
per_rental: int = 0
@dataclass(frozen=True)
class Invoice:
lines: tuple # (label, amount)
@property
def total(self) -> int:
return sum(a for _, a in self.lines)
class PricingPolicy(Protocol):
def charge(self, res: "Reservation", returned_at: int, damage_fee: int) -> Invoice:
"""Base days x rate + add-ons + late fee + damage; all from what actually happened."""
class RentalService:
def reserve(self, customer: str, branch_id: str, category: Category, window: Window, add_ons: tuple = ()) -> "Reservation": ...
def pickup(self, res_id: str, plate: str, odometer: int, fuel: int) -> None:
"""RESERVED -> ACTIVE; the vehicle must be free and of the reserved category."""
def return_vehicle(self, res_id: str, at: int, odometer: int, fuel: int, damage_fee: int = 0) -> Invoice:
"""ACTIVE -> RETURNED; frees the vehicle and the ledger; bills via PricingPolicy."""
Invariants: the per-category ledger of a branch never exceeds the number of vehicles of that category; a vehicle in status RENTED is referenced by exactly one active rental; an invoice is produced once, at return, from the recorded return time.
4. Design patterns and the choices behind them
Pricing as a strategy, evaluated at return
The force: rate cards change, late fees differ by branch, insurance add-ons are per day while a GPS is per rental. PricingPolicy.charge takes the reservation and the actual return time and produces an itemised Invoice, so a new rate card is a new class (strategy). Computing the price at booking and "adjusting" later is the alternative that goes wrong the first time a customer returns a day late.
Counted availability per category, vehicle chosen at pickup
Reservations hold a category at a branch over a window; the ledger counts reservations per day per category, the same interval counting used for hotel room types, so search is O(days). The exact vehicle is chosen at the counter from whatever is physically there, which is also what lets maintenance pull a car without touching reservations.
A reservation life cycle, found from the verbs
Reserve, pick up, return, cancel are four verbs and they map onto four statuses with legal moves enforced in one place (state). The nouns come from the use-case walk: the odometer and fuel readings are a value object recorded twice, which is why Rental exists as a separate class instead of six optional fields on the reservation.
5. Core flow and code
reserveasks the branch ledger to take the category for every day in the window; on success it stores aRESERVEDreservation.- At the counter,
pickuppicks the requested plate, checks it is available and of the right category, marks it rented, and records the pickup odometer and fuel in a newRental. - At return,
return_vehiclerecords the readings, frees the vehicle and the ledger days, and asksPricingPolicy.chargefor the invoice. cancelis allowed only fromRESERVED, raises from any other status, and gives the ledger days back.
import itertools, threading
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Optional
class VehicleStatus(Enum):
AVAILABLE = auto()
RENTED = auto()
MAINTENANCE = auto()
@dataclass
class Vehicle:
plate: str
category: Category
status: VehicleStatus = VehicleStatus.AVAILABLE
@dataclass(frozen=True)
class Odometer:
km: int
fuel_pct: int
@dataclass
class Rental:
vehicle: Vehicle
pickup: Odometer
returned: Optional[Odometer] = None
returned_at: Optional[int] = None
@dataclass
class Reservation:
id: str
customer: str
branch_id: str
category: Category
window: Window
add_ons: tuple = ()
status: ResStatus = ResStatus.RESERVED
rental: Optional[Rental] = None
class Availability:
def __init__(self, capacity: dict):
self.capacity = capacity # Category -> vehicle count
self.taken: dict = defaultdict(int) # (Category, day) -> reservations
self._lock = threading.Lock()
def _days(self, w: Window):
return range(w.start // 86400, -(-w.end // 86400))
def take(self, cat: Category, w: Window) -> bool:
with self._lock:
if any(self.taken[(cat, d)] >= self.capacity.get(cat, 0) for d in self._days(w)):
return False
for d in self._days(w):
self.taken[(cat, d)] += 1
return True
def give_back(self, cat: Category, w: Window) -> None:
with self._lock:
for d in self._days(w):
self.taken[(cat, d)] -= 1
class Branch:
def __init__(self, id: str, vehicles: list):
self.id = id
self.vehicles = {v.plate: v for v in vehicles}
self.lock = threading.Lock() # one counter at a time changes a rental
cap = defaultdict(int)
for v in vehicles:
cap[v.category] += 1
self.ledger = Availability(cap)
def free_vehicle(self, category: Category) -> Optional[Vehicle]:
return next((v for v in self.vehicles.values()
if v.category is category and v.status is VehicleStatus.AVAILABLE), None)
class StandardPricing:
RATES = {Category.ECONOMY: 2500, Category.SUV: 4500, Category.VAN: 6000} # per day, minor units
LATE_PER_HOUR = 500
def charge(self, res: Reservation, returned_at: int, damage_fee: int) -> Invoice:
days = res.window.days()
lines = [(f"{res.category.name} x {days} days", self.RATES[res.category] * days)]
for a in res.add_ons:
lines.append((a.name, a.per_day * days + a.per_rental))
late_hours = max(0, -(-(returned_at - res.window.end) // 3600))
if late_hours:
lines.append((f"late {late_hours} h", late_hours * self.LATE_PER_HOUR))
if damage_fee:
lines.append(("damage", damage_fee))
return Invoice(tuple(lines))
class RentalService:
def __init__(self, branches: list, pricing: PricingPolicy):
self.branches = {b.id: b for b in branches}
self.pricing = pricing
self.reservations: dict = {}
self._ids = itertools.count(1)
def search(self, branch_id: str, category: Category, window: Window) -> bool:
ledger = self.branches[branch_id].ledger
return all(ledger.taken.get((category, d), 0) < ledger.capacity.get(category, 0) for d in ledger._days(window))
def reserve(self, customer: str, branch_id: str, category: Category, window: Window, add_ons: tuple = ()) -> Reservation:
if window.end <= window.start:
raise ValueError("the return must be after the pickup")
if not self.branches[branch_id].ledger.take(category, window):
raise ValueError("no vehicle of that category for the window")
res = Reservation(f"RES{next(self._ids)}", customer, branch_id, category, window, add_ons)
self.reservations[res.id] = res
return res
def pickup(self, res_id: str, plate: str, odometer: int, fuel: int) -> None:
res = self.reservations[res_id]
branch = self.branches[res.branch_id]
with branch.lock:
if res.status is not ResStatus.RESERVED:
raise ValueError("not in RESERVED")
vehicle = branch.vehicles[plate]
if vehicle.status is not VehicleStatus.AVAILABLE or vehicle.category is not res.category:
raise ValueError("vehicle not available for this reservation")
vehicle.status = VehicleStatus.RENTED
res.rental = Rental(vehicle, Odometer(odometer, fuel))
res.status = ResStatus.ACTIVE
def return_vehicle(self, res_id: str, at: int, odometer: int, fuel: int, damage_fee: int = 0) -> Invoice:
res = self.reservations[res_id]
branch = self.branches[res.branch_id]
with branch.lock:
if res.status is not ResStatus.ACTIVE:
raise ValueError("not an active rental")
res.rental.returned, res.rental.returned_at = Odometer(odometer, fuel), at
res.rental.vehicle.status = VehicleStatus.AVAILABLE
branch.ledger.give_back(res.category, res.window)
res.status = ResStatus.RETURNED
return self.pricing.charge(res, at, damage_fee)
def cancel(self, res_id: str) -> None:
res = self.reservations[res_id]
branch = self.branches[res.branch_id]
with branch.lock:
if res.status is not ResStatus.RESERVED:
raise ValueError("only a reservation not yet picked up can be cancelled")
branch.ledger.give_back(res.category, res.window)
res.status = ResStatus.CANCELLED
The invoice is a tuple of labelled lines, not a single number, because the first follow-up is always "show me how the late fee was computed", and an itemised value object answers it without re-running the policy.
6. Extensions, concurrency and what interviewers probe
- "Weekend rates" or "a discount after seven days". A new
PricingPolicyor a decorator that wrapsStandardPricingand adjusts the invoice lines; nothing in the service changes. - "One-way rentals." The reservation gets a return branch; the ledger take is on the pickup branch and the vehicle moves on return. The vehicle-to-branch ownership becomes a mutable field, which is worth noting on the diagram.
- "The late return blocks the next customer." The ledger counted the vehicle as free from
window.end; a late return means the next pickup may find no physical vehicle, andpickupalready raises. Real systems keep a buffer day; say so. - Concurrency: the ledger's
takeis one critical section;pickup,return_vehicleandcancelrun under a branch lock, so two counters cannot hand over the same plate or bill one return twice (locks and mutexes). - Test first: billable days round up; a late return adds hours; cancelling after pickup is refused; the ledger is restored after return.
- Common mistake: pricing at reservation time, and attaching a specific vehicle to the reservation so a car sent to maintenance breaks a booking.
02Where this question was reported
- Educative · Grokking LLD chapter 15
- DesignGurus · DesignGurus case study
- dev.to · Educative top-10: branches, late fees, add-ons
- LLD Mastery · Medium; booking lifecycle, Decorator pricing (Uber/Lyft/Airbnb)
- Codemia · Codemia Medium
- GitHub · awesome-low-level-design, Medium