The 100 problems, solved / Booking, allocation and simulators / 059 · Design a car rental system

Design a car rental system

Vehicles, locations, reservations and pricing with late returns.

Concepts this design leans on

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 an Availability ledger 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.
FacadeFleetRental records1..*0..*0..*0..1RentalServicebranchesreservationspricingsearch(branch, category, window)reserve(...) -> Reservationpickup(res_id, plate, odo, fuel)return_vehicle(res_id, at, odo, fuel, damage) -> Invoicecancel(res_id)Branchidvehiclesledger: Availabilitylockfree_vehicle(category)VehicleplatecategorystatusReservationidcustomerbranch_idcategorywindowadd_onsstatusrental: Rental | NoneRentalvehiclepickup: Odometerreturned: Odometer | Nonereturned_at: int | NonePricingPolicycharge(res, returned_at, damage) -> Invoice

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

  1. reserve asks the branch ledger to take the category for every day in the window; on success it stores a RESERVED reservation.
  2. At the counter, pickup picks the requested plate, checks it is available and of the right category, marks it rented, and records the pickup odometer and fuel in a new Rental.
  3. At return, return_vehicle records the readings, frees the vehicle and the ledger days, and asks PricingPolicy.charge for the invoice.
  4. cancel is allowed only from RESERVED, 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 PricingPolicy or a decorator that wraps StandardPricing and 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, and pickup already raises. Real systems keep a buffer day; say so.
  • Concurrency: the ledger's take is one critical section; pickup, return_vehicle and cancel run 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