Low Level DesignWorked problems
Design a car rental system
Design a car rental system step by step for a low level design interview. Grow from renting a specific car to reservations by category and branch checked day by day, a reservation lifecycle where the car is chosen at pickup, one-way and late returns, pricing as a list of rules that produce a bill, and booking safely while many customers pay at once, with code in Java, Python, C#, Go and TypeScript.
- Read first
- StrategyThread safety basics
- Related
- Hotel bookingParking lot
“Design a car rental system like Hertz or Zoomcar.” Then: “what happens when two people book the last SUV at the same time?”
A car rental company owns cars at several branches, like an airport branch and a city branch. Customers book a kind of car, such as “an SUV”, at a branch for some days, often weeks ahead. They pick it up on the first day and bring it back at the end.
It looks like a parking lot with dates, and the dates are where it gets interesting. The system has to promise a car without knowing which one. Whether a booking fits depends on every other booking, on every day it covers. Cars come back late, or to a different branch, and the next customer still expects a car. The price is a stack of rules that marketing keeps adding to. And bookings arrive from many places at once.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- A booking is for a category of car at a branch, for a range of days, not for one car. The actual car is chosen at pickup, from whatever is on the lot.
- A booking fits if, on every day of its range, fewer cars are booked than the branch has. Counting every booking that overlaps the range is wrong: two bookings on different days can share one car.
- A reservation moves through states: reserved, active, returned, or cancelled. Each operation checks the state first.
- The price is a list of rules, each adding lines to a bill. The same rules give the quote before the trip and the invoice after it.
- Checking and recording a booking happen as one step, under a lock. Charging the card is slow, so it happens after, and a declined card cancels the booking.
We’ll build this in four stages, then handle many customers booking at once.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design a car rental system.
Whether you find what makes this different from a parking lot: dates in the future, a kind of
car instead of a car, and many branches. Jumping straight to classes for Car and Customer
without asking how bookings work is the red flag.
Here are the questions that matter:
- Do customers book ahead, or walk in? They book ahead, by whole days.
- Do they book a specific car, or a kind of car? A category, like economy, SUV or van.
- One location, or many? Many branches.
- Can a car be returned at another branch? Yes, for a fee. This is a one-way rental.
- How is the price made up? A daily rate, plus discounts and fees.
These answers give us one promise that matters more than the others: a branch never promises more cars of a category than it has, on any day. It’s the hard one, because it depends on every booking that touches each day. Two smaller promises come with it. A booking holds a car from the moment it’s recorded until it’s cancelled or the car comes back. And the bill comes from the same rules as the quote, so the two never disagree about the same trip.
We leave out hourly rentals, damage and repairs, and overbooking on purpose.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Hourly rentals? | Whole days | Hours instead of days; the day-by-day check becomes per slot |
| Maintenance and damage? | Out of scope | A car can be out of service, removed from the fleet count |
| Overbooking on purpose? | No | Airlines do it; a car rental could allow a few percent over |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram). Days are plain numbers, counted from some first day, to keep date arithmetic out of the way. A real system uses dates.
Stage 1: Rent a car that’s on the lot
Section titled “Stage 1: Rent a car that’s on the lot”Start with the simplest thing: a customer walks in and rents a car.
Whether you can build it in two minutes, and then say exactly why it’s not a rental business. Each problem you name sets up the next stage.
Start with the smallest thing that works. A Car records who is renting it, if anyone. The
RentalService rents a car by its id if it’s free, and takes it back.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
class Car { final String id; String rentedBy; // null while the car is on the lot
Car(String id) { this.id = id; }}
class RentalService { private final Map<String, Car> cars = new LinkedHashMap<>();
void addCar(String id) { cars.put(id, new Car(id)); }
// flaw 1: a customer can only take a car now. Nobody can book one for next week. // flaw 2: customers ask for a kind of car, not car KA-01-1234, and there is one lot. // flaw 3: nothing says what the rental costs. boolean rent(String carId, String customer) { Car car = cars.get(carId); if (car == null || car.rentedBy != null) return false; car.rentedBy = customer; return true; }
boolean giveBack(String carId) { Car car = cars.get(carId); if (car == null || car.rentedBy == null) return false; car.rentedBy = null; return true; }}class Car: def __init__(self, id: str): self.id = id self.rented_by: str | None = None # None while the car is on the lot
class RentalService: def __init__(self): self._cars: dict[str, Car] = {}
def add_car(self, id: str) -> None: self._cars[id] = Car(id)
# flaw 1: a customer can only take a car now. Nobody can book one for next week. # flaw 2: customers ask for a kind of car, not car KA-01-1234, and there is one lot. # flaw 3: nothing says what the rental costs. def rent(self, car_id: str, customer: str) -> bool: car = self._cars.get(car_id) if car is None or car.rented_by is not None: return False car.rented_by = customer return True
def give_back(self, car_id: str) -> bool: car = self._cars.get(car_id) if car is None or car.rented_by is None: return False car.rented_by = None return Truepublic sealed class Car{ public Car(string id) => Id = id;
public string Id { get; } public string? RentedBy { get; set; } // null while the car is on the lot}
public sealed class RentalService{ private readonly Dictionary<string, Car> _cars = new();
public void AddCar(string id) => _cars[id] = new Car(id);
// flaw 1: a customer can only take a car now. Nobody can book one for next week. // flaw 2: customers ask for a kind of car, not car KA-01-1234, and there is one lot. // flaw 3: nothing says what the rental costs. public bool Rent(string carId, string customer) { if (!_cars.TryGetValue(carId, out var car) || car.RentedBy is not null) return false; car.RentedBy = customer; return true; }
public bool GiveBack(string carId) { if (!_cars.TryGetValue(carId, out var car) || car.RentedBy is null) return false; car.RentedBy = null; return true; }}type Car struct { ID string RentedBy string // empty while the car is on the lot}
type RentalService struct { cars map[string]*Car}
func NewRentalService() *RentalService { return &RentalService{cars: map[string]*Car{}} }
func (s *RentalService) AddCar(id string) { s.cars[id] = &Car{ID: id} }
// flaw 1: a customer can only take a car now. Nobody can book one for next week.// flaw 2: customers ask for a kind of car, not car KA-01-1234, and there is one lot.// flaw 3: nothing says what the rental costs.func (s *RentalService) Rent(carID, customer string) bool { car, ok := s.cars[carID] if !ok || car.RentedBy != "" { return false } car.RentedBy = customer return true}
func (s *RentalService) GiveBack(carID string) bool { car, ok := s.cars[carID] if !ok || car.RentedBy == "" { return false } car.RentedBy = "" return true}class Car { rentedBy: string | null = null; // null while the car is on the lot
constructor(readonly id: string) {}}
class RentalService { private readonly cars = new Map<string, Car>();
addCar(id: string): void { this.cars.set(id, new Car(id)); }
// flaw 1: a customer can only take a car now. Nobody can book one for next week. // flaw 2: customers ask for a kind of car, not car KA-01-1234, and there is one lot. // flaw 3: nothing says what the rental costs. rent(carId: string, customer: string): boolean { const car = this.cars.get(carId); if (!car || car.rentedBy !== null) return false; car.rentedBy = customer; return true; }
giveBack(carId: string): boolean { const car = this.cars.get(carId); if (!car || car.rentedBy === null) return false; car.rentedBy = null; return true; }}It works, but it has three problems, and we leave them in on purpose. Nobody can book a car for next week. Customers ask for “an SUV”, not a licence plate, and there’s only one lot. And nothing says what anything costs.
Stage 2: Book a category at a branch, for some days
Section titled “Stage 2: Book a category at a branch, for some days”Customers book ahead: an SUV at the airport branch, days 3 to 7. How do you decide whether one is free?
Whether you check availability day by day. The red flag is “count the bookings that overlap the range and compare with the fleet”: it refuses bookings that fit.
A DateRange is a start day and an end day. The end is exclusive, meaning the range stops
just before it. So a rental for days 3 to 5 hands the car back on day 5, and the next rental can
start that same morning. A Reservation is a customer, a category, a branch and a date
range. Each car now has a category, and the branch it belongs to.
A booking fits if, on every day of its range, the branch has more cars of that category than there are bookings covering that day.
The tempting shortcut is to count every booking that overlaps the range. It’s wrong. Take a branch with two SUVs. Ana has booked days 1 to 3, and Ben days 5 to 7. Now Cy asks for days 2 to 6. Both existing bookings overlap Cy’s range, so the shortcut counts two, sees a full branch, and refuses. But Ana is gone by day 3, and Ben doesn’t start until day 5. On every day of Cy’s range, at most one SUV is out, so Cy fits.
Checking each day costs the number of days times the number of bookings. That’s tiny for a branch: a week’s booking against a few hundred bookings is a few thousand comparisons.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// What a customer books: a kind of car, not a particular one.enum Category { ECONOMY, SUV, VAN }
// Days as numbers counted from some first day, so the arithmetic stays simple. The end is// exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental// can start that same day.record DateRange(int start, int end) { DateRange { if (end <= start) throw new IllegalArgumentException("a rental is at least one day"); }
int days() { return end - start; }
boolean contains(int day) { return day >= start && day < end; }}
class Car { final String id; final Category category; String branch;
Car(String id, Category category, String branch) { this.id = id; this.category = category; this.branch = branch; }}
enum Status { RESERVED, CANCELLED }
// A reservation is for a category at a branch, not for a car. Which car the customer drives// away in is decided when they arrive.class Reservation { final String id; final String customer; final Category category; final String branch; final DateRange range; Status status = Status.RESERVED;
Reservation(String id, String customer, Category category, String branch, DateRange range) { this.id = id; this.customer = customer; this.category = category; this.branch = branch; this.range = range; }
boolean holdsACar() { return status == Status.RESERVED; }}
class RentalService { private final List<Car> cars = new ArrayList<>(); private final Map<String, Reservation> reservations = new LinkedHashMap<>(); private int nextId = 1;
void addCar(String id, Category category, String branch) { cars.add(new Car(id, category, branch)); }
// Returns null when every car of that category at that branch is taken on some day of the // range. Reservation reserve(String customer, Category category, String branch, DateRange range) { if (!available(category, branch, range)) return null; Reservation r = new Reservation("R" + nextId++, customer, category, branch, range); reservations.put(r.id, r); return r; }
boolean cancel(String id) { Reservation r = reservations.get(id); if (r == null || r.status != Status.RESERVED) return false; r.status = Status.CANCELLED; return true; }
Reservation get(String id) { return reservations.get(id); }
// Free on every day of the range: for each day, fewer reservations than cars. Counting every // reservation that overlaps the range would be wrong: two that overlap it on different days // need only one car between them. boolean available(Category category, String branch, DateRange range) { long fleet = cars.stream().filter(c -> c.category == category && c.branch.equals(branch)).count(); for (int day = range.start(); day < range.end(); day++) { int d = day; long booked = reservations.values().stream() .filter(r -> r.holdsACar() && r.category == category && r.branch.equals(branch) && r.range.contains(d)) .count(); if (booked >= fleet) return false; } return true; }}# What a customer books: a kind of car, not a particular one.class Category(Enum): ECONOMY = auto() SUV = auto() VAN = auto()
# Days as numbers counted from some first day, so the arithmetic stays simple. The end is# exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental# can start that same day.@dataclass(frozen=True)class DateRange: start: int end: int
def __post_init__(self): if self.end <= self.start: raise ValueError("a rental is at least one day")
@property def days(self) -> int: return self.end - self.start
def contains(self, day: int) -> bool: return self.start <= day < self.end
class Car: def __init__(self, id: str, category: Category, branch: str): self.id = id self.category = category self.branch = branch
class Status(Enum): RESERVED = auto() CANCELLED = auto()
# A reservation is for a category at a branch, not for a car. Which car the customer# drives away in is decided when they arrive.@dataclass(eq=False)class Reservation: id: str customer: str category: Category branch: str range: DateRange status: Status = Status.RESERVED
def holds_a_car(self) -> bool: return self.status is Status.RESERVED
class RentalService: def __init__(self): self._cars: list[Car] = [] self._reservations: dict[str, Reservation] = {} self._next_id = 1
def add_car(self, id: str, category: Category, branch: str) -> None: self._cars.append(Car(id, category, branch))
# None when every car of that category at that branch is taken on some day of the range. def reserve(self, customer: str, category: Category, branch: str, dates: DateRange) -> Reservation | None: if not self.available(category, branch, dates): return None r = Reservation(f"R{self._next_id}", customer, category, branch, dates) self._next_id += 1 self._reservations[r.id] = r return r
def cancel(self, id: str) -> bool: r = self._reservations.get(id) if r is None or r.status is not Status.RESERVED: return False r.status = Status.CANCELLED return True
def get(self, id: str) -> Reservation | None: return self._reservations.get(id)
# Free on every day of the range: for each day, fewer reservations than cars. Counting # every reservation that overlaps the range would be wrong: two that overlap it on # different days need only one car between them. def available(self, category: Category, branch: str, dates: DateRange) -> bool: fleet = sum(1 for c in self._cars if c.category is category and c.branch == branch) for day in range(dates.start, dates.end): booked = sum( 1 for r in self._reservations.values() if r.holds_a_car() and r.category is category and r.branch == branch and r.range.contains(day) ) if booked >= fleet: return False return True// What a customer books: a kind of car, not a particular one.public enum Category { Economy, Suv, Van }
// Days as numbers counted from some first day, so the arithmetic stays simple. The end is// exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental// can start that same day.public readonly record struct DateRange{ public DateRange(int start, int end) { if (end <= start) throw new ArgumentException("a rental is at least one day"); Start = start; End = end; }
public int Start { get; } public int End { get; } public int Days => End - Start; public bool Contains(int day) => day >= Start && day < End;}
public sealed class Car{ public Car(string id, Category category, string branch) => (Id, Category, Branch) = (id, category, branch);
public string Id { get; } public Category Category { get; } public string Branch { get; set; }}
public enum Status { Reserved, Cancelled }
// A reservation is for a category at a branch, not for a car. Which car the customer drives// away in is decided when they arrive.public sealed class Reservation{ public Reservation(string id, string customer, Category category, string branch, DateRange range) => (Id, Customer, Category, Branch, Range) = (id, customer, category, branch, range);
public string Id { get; } public string Customer { get; } public Category Category { get; } public string Branch { get; } public DateRange Range { get; } public Status Status { get; set; } = Status.Reserved; public bool HoldsACar => Status == Status.Reserved;}
public sealed class RentalService{ private readonly List<Car> _cars = new(); private readonly Dictionary<string, Reservation> _reservations = new(); private int _nextId = 1;
public void AddCar(string id, Category category, string branch) => _cars.Add(new Car(id, category, branch));
// Returns null when every car of that category at that branch is taken on some day of the // range. public Reservation? Reserve(string customer, Category category, string branch, DateRange range) { if (!Available(category, branch, range)) return null; var r = new Reservation($"R{_nextId++}", customer, category, branch, range); _reservations[r.Id] = r; return r; }
public bool Cancel(string id) { if (!_reservations.TryGetValue(id, out var r) || r.Status != Status.Reserved) return false; r.Status = Status.Cancelled; return true; }
public Reservation? Get(string id) => _reservations.GetValueOrDefault(id);
// Free on every day of the range: for each day, fewer reservations than cars. Counting every // reservation that overlaps the range would be wrong: two that overlap it on different days // need only one car between them. public bool Available(Category category, string branch, DateRange range) { int fleet = _cars.Count(c => c.Category == category && c.Branch == branch); for (int day = range.Start; day < range.End; day++) { int booked = _reservations.Values.Count(r => r.HoldsACar && r.Category == category && r.Branch == branch && r.Range.Contains(day)); if (booked >= fleet) return false; } return true; }}// Category is what a customer books: a kind of car, not a particular one.type Category int
const ( Economy Category = iota SUV Van)
func (c Category) String() string { return [...]string{"economy", "SUV", "van"}[c] }
// DateRange counts days as numbers from some first day, so the arithmetic stays simple. End is// exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental can// start that same day. Callers make sure End > Start.type DateRange struct{ Start, End int }
func (d DateRange) Days() int { return d.End - d.Start }
func (d DateRange) Contains(day int) bool { return day >= d.Start && day < d.End }
type Car struct { ID string Category Category Branch string}
type Status int
const ( Reserved Status = iota Cancelled)
// Reservation is for a category at a branch, not for a car. Which car the customer drives away// in is decided when they arrive.type Reservation struct { ID string Customer string Category Category Branch string Range DateRange Status Status}
func (r *Reservation) HoldsACar() bool { return r.Status == Reserved }
// RentalService holds the fleet and the bookings. Its main question: on every day of this// range, is a car of this kind still left at this branch?type RentalService struct { cars []*Car reservations map[string]*Reservation nextID int}
func NewRentalService() *RentalService { return &RentalService{reservations: map[string]*Reservation{}, nextID: 1}}
func (s *RentalService) AddCar(id string, c Category, branch string) { s.cars = append(s.cars, &Car{ID: id, Category: c, Branch: branch})}
// Reserve returns nil when every car of that category at that branch is taken on some day of// the range.func (s *RentalService) Reserve(customer string, c Category, branch string, dates DateRange) *Reservation { if !s.Available(c, branch, dates) { return nil } r := &Reservation{ID: fmt.Sprintf("R%d", s.nextID), Customer: customer, Category: c, Branch: branch, Range: dates} s.nextID++ s.reservations[r.ID] = r return r}
func (s *RentalService) Cancel(id string) bool { r, ok := s.reservations[id] if !ok || r.Status != Reserved { return false } r.Status = Cancelled return true}
func (s *RentalService) Get(id string) *Reservation { return s.reservations[id] }
// Available means free on every day of the range: for each day, fewer reservations than cars.// Counting every reservation that overlaps the range would be wrong: two that overlap it on// different days need only one car between them.func (s *RentalService) Available(c Category, branch string, dates DateRange) bool { fleet := 0 for _, car := range s.cars { if car.Category == c && car.Branch == branch { fleet++ } } for day := dates.Start; day < dates.End; day++ { booked := 0 for _, r := range s.reservations { if r.HoldsACar() && r.Category == c && r.Branch == branch && r.Range.Contains(day) { booked++ } } if booked >= fleet { return false } } return true}// What a customer books: a kind of car, not a particular one.type Category = 'economy' | 'suv' | 'van';
// Days as numbers counted from some first day, so the arithmetic stays simple. The end is// exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental can// start that same day.class DateRange { constructor( readonly start: number, readonly end: number, ) { if (end <= start) throw new Error('a rental is at least one day'); }
get days(): number { return this.end - this.start; }
contains(day: number): boolean { return day >= this.start && day < this.end; }}
class Car { constructor( readonly id: string, readonly category: Category, public branch: string, ) {}}
type Status = 'reserved' | 'cancelled';
// A reservation is for a category at a branch, not for a car. Which car the customer drives away// in is decided when they arrive.class Reservation { status: Status = 'reserved';
constructor( readonly id: string, readonly customer: string, readonly category: Category, readonly branch: string, readonly range: DateRange, ) {}
holdsACar(): boolean { return this.status === 'reserved'; }}
class RentalService { private readonly cars: Car[] = []; private readonly reservations = new Map<string, Reservation>(); private nextId = 1;
addCar(id: string, category: Category, branch: string): void { this.cars.push(new Car(id, category, branch)); }
// Returns null when every car of that category at that branch is taken on some day of the // range. reserve(customer: string, category: Category, branch: string, range: DateRange): Reservation | null { if (!this.available(category, branch, range)) return null; const r = new Reservation(`R${this.nextId++}`, customer, category, branch, range); this.reservations.set(r.id, r); return r; }
cancel(id: string): boolean { const r = this.reservations.get(id); if (!r || r.status !== 'reserved') return false; r.status = 'cancelled'; return true; }
get(id: string): Reservation | undefined { return this.reservations.get(id); }
// Free on every day of the range: for each day, fewer reservations than cars. Counting every // reservation that overlaps the range would be wrong: two that overlap it on different days need // only one car between them. available(category: Category, branch: string, range: DateRange): boolean { const fleet = this.cars.filter((c) => c.category === category && c.branch === branch).length; for (let day = range.start; day < range.end; day++) { let booked = 0; for (const r of this.reservations.values()) { if (r.holdsACar() && r.category === category && r.branch === branch && r.range.contains(day)) booked++; } if (booked >= fleet) return false; } return true; }}Go deeperChecking Cy's booking, day by day1 min
Cy wants days 2 to 6, which is days 2, 3, 4 and 5 (the end is exclusive). The branch has two SUVs.
| Day | Ana (1 to 3) | Ben (5 to 7) | Booked | Room for Cy? |
|---|---|---|---|---|
| 2 | out | 1 | Yes | |
| 3 | 0 | Yes | ||
| 4 | 0 | Yes | ||
| 5 | out | 1 | Yes |
Every day has room, so the booking fits. If a third customer, Di, already had days 4 to 6, then day 5 would have two SUVs out, and Cy would be refused. It’s the busiest day that decides, not the number of bookings nearby.
Stage 3: Pickup, return, and the reservation’s life
Section titled “Stage 3: Pickup, return, and the reservation’s life”The customer arrives. Which car do they get? And what happens when they bring it back two days late, to a different branch?
Whether you model the reservation’s states and decide when the car is chosen. The red flag is a booking that can be picked up twice, or cancelled while the car is out.
A reservation now moves through a small set of states. It’s reserved when booked, becomes
active at pickup, and returned when the car comes back. It can be cancelled only while
it’s reserved. Every operation checks the state first, and refuses with WRONG_STATE otherwise.
This is the State pattern at its simplest: an enum and a check, because the rules are few.
- Color key
- State
- Ends well
Pickup is where a reservation becomes a car. On the first day, the service takes any car of the category that is at the branch and not out on a rental, and records it on the reservation. Return works on any day, at any branch. The reservation records the day and the branch, which pricing needs. The car now belongs to the branch it was returned to.
Why choose the car at pickup, not at booking? Because then a car that breaks down, comes back late, or ends up at another branch doesn’t break any booking. Any other car of the category will do. Hotels make the same choice: you book a room type, and get the room number at check-in.
Both awkward cases follow from this. A late return keeps a car out past its booking. The
availability check counted that car as free from the booking’s end, so the next customer may
arrive to find no car on the lot. Pickup then says NO_CAR, and the counter staff upgrade the
customer or call another branch. A one-way return moves a car between branches, so one
branch’s fleet grows and the other’s shrinks. Both are real business problems with business
answers. What the design must do is notice them, not hide them.
Go deeperWhen the promised car isn't on the lot2 min
Let’s follow the late-return case through, because interviewers like to push on it.
- The airport branch has two SUVs. Ana has one for days 1 to 5. Ben has booked the other for days 3 to 6. Cy has booked days 5 to 8. On day 5, the count says one SUV is free for Cy: Ana’s car.
- Ana doesn’t come back on day 5. Her reservation is still active.
- Cy arrives on day 5. Pickup looks for an SUV at the branch that isn’t out on a rental. Ana’s is
out, and Ben’s is out. Pickup returns
NO_CAR. - The booking itself is fine: it’s still reserved, and the counter can act. The usual answers are an upgrade (give Cy a van at the SUV price), a car moved from a nearby branch, or a voucher if nothing works.
- When Ana returns on day 7,
LateFeecharges her two late days at the higher rate. That higher rate exists precisely because her lateness cost someone else a car.
Could the design prevent this? Only by keeping a spare car of every category at every branch, which
costs real money every day. Rental companies choose to handle it at the counter instead. The design
must make the problem visible at the moment it happens, and NO_CAR does that.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A reservation's life: booked, picked up, returned. It can be cancelled only before pickup.enum Status { RESERVED, ACTIVE, RETURNED, CANCELLED }
// What a request gets back. NOT_TODAY: pickup is only on the first day. NO_CAR: the booking// counted a car, but none is on the lot right now.enum Result { OK, NOT_FOUND, WRONG_STATE, NOT_TODAY, NO_CAR }
// One car in the fleet. Its branch changes when it is returned somewhere else.class Car { final String id; final Category category; String branch; Reservation rental; // the rental using it now, or null while it is on the lot
Car(String id, Category category, String branch) { this.id = id; this.category = category; this.branch = branch; }}
// A booking for a kind of car at a branch, for some days. The actual car is chosen at pickup,// so any free car of that kind will do.class Reservation { final String id; final String customer; final Category category; final String branch; final DateRange range; Status status = Status.RESERVED; Car car; // set at pickup int returnedOn; // set at return String returnBranch; // set at return; differs from branch for a one-way rental
Reservation(String id, String customer, Category category, String branch, DateRange range) { this.id = id; this.customer = customer; this.category = category; this.branch = branch; this.range = range; }
// Reserved and active rentals need a car. Cancelled and returned ones don't. boolean holdsACar() { return status == Status.RESERVED || status == Status.ACTIVE; }}
// The fleet and the bookings. Its main question: on every day of this range, is a car of this// kind still left at this branch?class RentalService { private final List<Car> cars = new ArrayList<>(); private final Map<String, Reservation> reservations = new LinkedHashMap<>(); private int nextId = 1;
void addCar(String id, Category category, String branch) { cars.add(new Car(id, category, branch)); }
// Returns null when no car is free for every day of the range. Reservation reserve(String customer, Category category, String branch, DateRange range) { if (!available(category, branch, range)) return null; Reservation r = new Reservation("R" + nextId++, customer, category, branch, range); reservations.put(r.id, r); return r; }
Result cancel(String id) { Reservation r = reservations.get(id); if (r == null) return Result.NOT_FOUND; if (r.status != Status.RESERVED) return Result.WRONG_STATE; r.status = Status.CANCELLED; return Result.OK; }
// The customer arrives on the first day. Any car of the category that is on this branch's lot // will do; this is where a reservation becomes a car. Result pickup(String id, int today) { Reservation r = reservations.get(id); if (r == null) return Result.NOT_FOUND; if (r.status != Status.RESERVED) return Result.WRONG_STATE; if (today != r.range.start()) return Result.NOT_TODAY; Car car = cars.stream() .filter(c -> c.category == r.category && c.branch.equals(r.branch) && c.rental == null) .findFirst().orElse(null); if (car == null) return Result.NO_CAR; // a car came back late, or went one way car.rental = r; r.car = car; r.status = Status.ACTIVE; return Result.OK; }
// Any day, at any branch. A car returned elsewhere now belongs to that branch's lot. Result giveBack(String id, int today, String branch) { Reservation r = reservations.get(id); if (r == null) return Result.NOT_FOUND; if (r.status != Status.ACTIVE) return Result.WRONG_STATE; r.status = Status.RETURNED; r.returnedOn = today; r.returnBranch = branch; r.car.rental = null; r.car.branch = branch; return Result.OK; }
Reservation get(String id) { return reservations.get(id); }
// For each day, compare the cars this branch has with the bookings that need one that day. One // full day is enough to say no. boolean available(Category category, String branch, DateRange range) { long fleet = cars.stream().filter(c -> c.category == category && c.branch.equals(branch)).count(); for (int day = range.start(); day < range.end(); day++) { int d = day; long booked = reservations.values().stream() .filter(r -> r.holdsACar() && r.category == category && r.branch.equals(branch) && r.range.contains(d)) .count(); if (booked >= fleet) return false; } return true; }}# A reservation's life: booked, picked up, returned. It can be cancelled only before pickup.class Status(Enum): RESERVED = auto() ACTIVE = auto() RETURNED = auto() CANCELLED = auto()
# What a request gets back. NOT_TODAY: pickup is only on the first day. NO_CAR: the booking# counted a car, but none is on the lot right now.class Result(Enum): OK = auto() NOT_FOUND = auto() WRONG_STATE = auto() NOT_TODAY = auto() NO_CAR = auto()
# One car in the fleet. Its branch changes when it is returned somewhere else.class Car: def __init__(self, id: str, category: Category, branch: str): self.id = id self.category = category self.branch = branch self.rental: Reservation | None = None # the rental using it now, or None on the lot
# A booking for a kind of car at a branch, for some days. The actual car is chosen at# pickup, so any free car of that kind will do.@dataclass(eq=False)class Reservation: id: str customer: str category: Category branch: str range: DateRange status: Status = Status.RESERVED car: Car | None = None # set at pickup returned_on: int = 0 # set at return return_branch: str = "" # set at return; differs from branch for a one-way rental
# Reserved and active rentals need a car. Cancelled and returned ones don't. def holds_a_car(self) -> bool: return self.status in (Status.RESERVED, Status.ACTIVE)
# The fleet and the bookings. Its main question: on every day of this range, is a car of# this kind still left at this branch?class RentalService: def __init__(self): self._cars: list[Car] = [] self._reservations: dict[str, Reservation] = {} self._next_id = 1
def add_car(self, id: str, category: Category, branch: str) -> None: self._cars.append(Car(id, category, branch))
# Returns None when no car is free for every day of the range. def reserve(self, customer: str, category: Category, branch: str, dates: DateRange) -> Reservation | None: if not self.available(category, branch, dates): return None r = Reservation(f"R{self._next_id}", customer, category, branch, dates) self._next_id += 1 self._reservations[r.id] = r return r
def cancel(self, id: str) -> Result: r = self._reservations.get(id) if r is None: return Result.NOT_FOUND if r.status is not Status.RESERVED: return Result.WRONG_STATE r.status = Status.CANCELLED return Result.OK
# The customer arrives on the first day. Any car of the category on this branch's lot # will do; this is where a reservation becomes a car. def pickup(self, id: str, today: int) -> Result: r = self._reservations.get(id) if r is None: return Result.NOT_FOUND if r.status is not Status.RESERVED: return Result.WRONG_STATE if today != r.range.start: return Result.NOT_TODAY car = next( (c for c in self._cars if c.category is r.category and c.branch == r.branch and c.rental is None), None, ) if car is None: return Result.NO_CAR # a car came back late, or went one way car.rental = r r.car = car r.status = Status.ACTIVE return Result.OK
# Any day, at any branch. A car returned elsewhere now belongs to that branch's lot. def give_back(self, id: str, today: int, branch: str) -> Result: r = self._reservations.get(id) if r is None: return Result.NOT_FOUND if r.status is not Status.ACTIVE: return Result.WRONG_STATE r.status = Status.RETURNED r.returned_on = today r.return_branch = branch r.car.rental = None r.car.branch = branch return Result.OK
def get(self, id: str) -> Reservation | None: return self._reservations.get(id)
# For each day, compare the cars this branch has with the bookings that need one that # day. One full day is enough to say no. def available(self, category: Category, branch: str, dates: DateRange) -> bool: fleet = sum(1 for c in self._cars if c.category is category and c.branch == branch) for day in range(dates.start, dates.end): booked = sum( 1 for r in self._reservations.values() if r.holds_a_car() and r.category is category and r.branch == branch and r.range.contains(day) ) if booked >= fleet: return False return True// A reservation's life: booked, picked up, returned. It can be cancelled only before pickup.public enum Status { Reserved, Active, Returned, Cancelled }
// What a request gets back. NotToday: pickup is only on the first day. NoCar: the booking// counted a car, but none is on the lot right now.public enum Result { Ok, NotFound, WrongState, NotToday, NoCar }
// One car in the fleet. Its branch changes when it is returned somewhere else.public sealed class Car{ public Car(string id, Category category, string branch) => (Id, Category, Branch) = (id, category, branch);
public string Id { get; } public Category Category { get; } public string Branch { get; set; } public Reservation? Rental { get; set; } // the rental using it now, or null on the lot}
// A booking for a kind of car at a branch, for some days. The actual car is chosen at pickup,// so any free car of that kind will do.public sealed class Reservation{ public Reservation(string id, string customer, Category category, string branch, DateRange range) => (Id, Customer, Category, Branch, Range) = (id, customer, category, branch, range);
public string Id { get; } public string Customer { get; } public Category Category { get; } public string Branch { get; } public DateRange Range { get; } public Status Status { get; set; } = Status.Reserved; public Car? Car { get; set; } // set at pickup public int ReturnedOn { get; set; } // set at return public string ReturnBranch { get; set; } = ""; // set at return; differs for a one-way rental // Reserved and active rentals need a car. Cancelled and returned ones don't. public bool HoldsACar => Status is Status.Reserved or Status.Active;}
// The fleet and the bookings. Its main question: on every day of this range, is a car of this// kind still left at this branch?public sealed class RentalService{ private readonly List<Car> _cars = new(); private readonly Dictionary<string, Reservation> _reservations = new(); private int _nextId = 1;
public void AddCar(string id, Category category, string branch) => _cars.Add(new Car(id, category, branch));
// Returns null when no car is free for every day of the range. public Reservation? Reserve(string customer, Category category, string branch, DateRange range) { if (!Available(category, branch, range)) return null; var r = new Reservation($"R{_nextId++}", customer, category, branch, range); _reservations[r.Id] = r; return r; }
public Result Cancel(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NotFound; if (r.Status != Status.Reserved) return Result.WrongState; r.Status = Status.Cancelled; return Result.Ok; }
// The customer arrives on the first day. Any car of the category on this branch's lot will // do; this is where a reservation becomes a car. public Result Pickup(string id, int today) { if (!_reservations.TryGetValue(id, out var r)) return Result.NotFound; if (r.Status != Status.Reserved) return Result.WrongState; if (today != r.Range.Start) return Result.NotToday; var car = _cars.FirstOrDefault(c => c.Category == r.Category && c.Branch == r.Branch && c.Rental is null); if (car is null) return Result.NoCar; // a car came back late, or went one way car.Rental = r; r.Car = car; r.Status = Status.Active; return Result.Ok; }
// Any day, at any branch. A car returned elsewhere now belongs to that branch's lot. public Result GiveBack(string id, int today, string branch) { if (!_reservations.TryGetValue(id, out var r)) return Result.NotFound; if (r.Status != Status.Active) return Result.WrongState; r.Status = Status.Returned; r.ReturnedOn = today; r.ReturnBranch = branch; r.Car!.Rental = null; r.Car.Branch = branch; return Result.Ok; }
public Reservation? Get(string id) => _reservations.GetValueOrDefault(id);
// For each day, compare the cars this branch has with the bookings that need one that day. One // full day is enough to say no. public bool Available(Category category, string branch, DateRange range) { int fleet = _cars.Count(c => c.Category == category && c.Branch == branch); for (int day = range.Start; day < range.End; day++) { int booked = _reservations.Values.Count(r => r.HoldsACar && r.Category == category && r.Branch == branch && r.Range.Contains(day)); if (booked >= fleet) return false; } return true; }}// Status is a reservation's life: booked, picked up, returned. It can be cancelled only before// pickup.type Status int
const ( Reserved Status = iota Active Returned Cancelled)
// Result is what a request gets back. NotToday: pickup is only on the first day. NoCar: the// booking counted a car, but none is on the lot right now.type Result int
const ( OK Result = iota NotFound WrongState NotToday NoCar)
// Car is one car in the fleet. Its branch changes when it is returned somewhere else.type Car struct { ID string Category Category Branch string Rental *Reservation // the rental using it now, or nil while it is on the lot}
// Reservation is a booking for a kind of car at a branch, for some days. The actual car is// chosen at pickup, so any free car of that kind will do.type Reservation struct { ID string Customer string Category Category Branch string Range DateRange Status Status Car *Car // set at pickup ReturnedOn int // set at return ReturnBranch string // set at return; differs from Branch for a one-way rental}
// HoldsACar is true for reserved and active rentals. Cancelled and returned ones need no car.func (r *Reservation) HoldsACar() bool { return r.Status == Reserved || r.Status == Active }
func (s *RentalService) Cancel(id string) Result { r, ok := s.reservations[id] if !ok { return NotFound } if r.Status != Reserved { return WrongState } r.Status = Cancelled return OK}
// Pickup happens on the first day. Any car of the category on this branch's lot will do; this// is where a reservation becomes a car.func (s *RentalService) Pickup(id string, today int) Result { r, ok := s.reservations[id] if !ok { return NotFound } if r.Status != Reserved { return WrongState } if today != r.Range.Start { return NotToday } for _, car := range s.cars { if car.Category == r.Category && car.Branch == r.Branch && car.Rental == nil { car.Rental = r r.Car = car r.Status = Active return OK } } return NoCar // a car came back late, or went one way}
// GiveBack works on any day, at any branch. A car returned elsewhere now belongs to that// branch's lot.func (s *RentalService) GiveBack(id string, today int, branch string) Result { r, ok := s.reservations[id] if !ok { return NotFound } if r.Status != Active { return WrongState } r.Status = Returned r.ReturnedOn = today r.ReturnBranch = branch r.Car.Rental = nil r.Car.Branch = branch return OK}// A reservation's life: booked, picked up, returned. It can be cancelled only before pickup.type Status = 'reserved' | 'active' | 'returned' | 'cancelled';
// What a request gets back. not-today: pickup is only on the first day. no-car: the booking// counted a car, but none is on the lot right now.type Result = 'ok' | 'not-found' | 'wrong-state' | 'not-today' | 'no-car';
// One car in the fleet. Its branch changes when it is returned somewhere else.class Car { rental: Reservation | null = null; // the rental using it now, or null while it is on the lot
constructor( readonly id: string, readonly category: Category, public branch: string, ) {}}
// A booking for a kind of car at a branch, for some days. The actual car is chosen at pickup, so// any free car of that kind will do.class Reservation { status: Status = 'reserved'; car: Car | null = null; // set at pickup returnedOn = 0; // set at return returnBranch = ''; // set at return; differs from branch for a one-way rental
constructor( readonly id: string, readonly customer: string, readonly category: Category, readonly branch: string, readonly range: DateRange, ) {}
// Reserved and active rentals need a car. Cancelled and returned ones don't. holdsACar(): boolean { return this.status === 'reserved' || this.status === 'active'; }}
// The fleet and the bookings. Its main question: on every day of this range, is a car of this// kind still left at this branch?class RentalService { private readonly cars: Car[] = []; private readonly reservations = new Map<string, Reservation>(); private nextId = 1;
addCar(id: string, category: Category, branch: string): void { this.cars.push(new Car(id, category, branch)); }
// Returns null when no car is free for every day of the range. reserve(customer: string, category: Category, branch: string, range: DateRange): Reservation | null { if (!this.available(category, branch, range)) return null; const r = new Reservation(`R${this.nextId++}`, customer, category, branch, range); this.reservations.set(r.id, r); return r; }
cancel(id: string): Result { const r = this.reservations.get(id); if (!r) return 'not-found'; if (r.status !== 'reserved') return 'wrong-state'; r.status = 'cancelled'; return 'ok'; }
// The customer arrives on the first day. Any car of the category on this branch's lot will do; // this is where a reservation becomes a car. pickup(id: string, today: number): Result { const r = this.reservations.get(id); if (!r) return 'not-found'; if (r.status !== 'reserved') return 'wrong-state'; if (today !== r.range.start) return 'not-today'; const car = this.cars.find((c) => c.category === r.category && c.branch === r.branch && c.rental === null); if (!car) return 'no-car'; // a car came back late, or went one way car.rental = r; r.car = car; r.status = 'active'; return 'ok'; }
// Any day, at any branch. A car returned elsewhere now belongs to that branch's lot. giveBack(id: string, today: number, branch: string): Result { const r = this.reservations.get(id); if (!r) return 'not-found'; if (r.status !== 'active') return 'wrong-state'; r.status = 'returned'; r.returnedOn = today; r.returnBranch = branch; r.car!.rental = null; r.car!.branch = branch; return 'ok'; }
get(id: string): Reservation | undefined { return this.reservations.get(id); }
// For each day, compare the cars this branch has with the bookings that need one that day. One // full day is enough to say no. available(category: Category, branch: string, range: DateRange): boolean { const fleet = this.cars.filter((c) => c.category === category && c.branch === branch).length; for (let day = range.start; day < range.end; day++) { let booked = 0; for (const r of this.reservations.values()) { if (r.holdsACar() && r.category === category && r.branch === branch && r.range.contains(day)) booked++; } if (booked >= fleet) return false; } return true; }}Go deeperWhen to choose the car, side by side1 min
| At booking | At pickup (our choice) | |
|---|---|---|
| Availability check | An overlap check per car | A day-by-day count per category |
| A car breaks down or comes back late | Its bookings must be moved by hand | Any other car of the category will do |
| One-way returns | Future bookings on that car break | The branch’s count changes; bookings stand |
| The customer wants a specific car | Possible | Not without an extra rule |
Stage 4: Pricing as rules
Section titled “Stage 4: Pricing as rules”A week costs less per day than a weekend. One-way rentals cost extra. Late returns are charged at one and a half times the rate. Marketing will add more. Where does that logic live?
Whether you keep pricing out of the reservation and the service, and make each charge its own
rule. The red flag is a calculatePrice method that grows an if for every promotion.
Each kind of charge becomes a PriceRule. Given the facts of a rental, it returns zero or more
lines for the bill. The facts are a Charge: the category, the booked range, the day the car
came back, and the pickup and return branches. Four rules cover what we need:
DailyRatecharges each booked day at the category’s rate.LongRentalDiscounttakes a percentage off when the booking is a week or longer.OneWayFeeadds a fee when the return branch differs from the pickup branch.LateFeecharges each day past the booked end at a higher rate, because the car may have been promised to someone else.
Pricing runs every rule, and collects the lines into a Bill. Before the trip, it builds a
Charge from the plan (returning on time, at the same branch), and that’s the quote. After the
trip, it builds one from what actually happened, and that’s the invoice. The same rules make
both, so a customer who returns on time to the same branch pays exactly what they were quoted.
Money is kept in cents, so the lines add up exactly.
Each rule is swappable, and new ones don’t touch the others. That’s the Strategy pattern.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Money in cents, so totals add up exactly.record Line(String label, long cents) {}
// A bill keeps its lines, not just the total, so the customer sees what each charge is for.record Bill(List<Line> lines) { long total() { return lines.stream().mapToLong(Line::cents).sum(); }}
// What a price rule can see. Before pickup it's a quote: returnedOn is the planned end and// returnBranch the pickup branch. At return it's the real thing.record Charge(Category category, DateRange range, int returnedOn, String pickupBranch, String returnBranch) { int daysCharged() { return Math.max(range.days(), returnedOn - range.start()); }
int lateDays() { return Math.max(0, returnedOn - range.end()); }}
// One rule per kind of charge. A new fee, a promotion or a different rate card is a new rule,// and the service that adds up the bill doesn't change.interface PriceRule { List<Line> apply(Charge c);}
// The base price: booked days times the category's daily rate.record DailyRate(Map<Category, Long> perDay) implements PriceRule { public List<Line> apply(Charge c) { return List.of(new Line(c.range().days() + " days of " + c.category(), c.range().days() * perDay.get(c.category()))); }}
// Long rentals pay less per day: a percentage off the booked days.record LongRentalDiscount(int minDays, int percent, Map<Category, Long> perDay) implements PriceRule { public List<Line> apply(Charge c) { if (c.range().days() < minDays) return List.of(); long off = c.range().days() * perDay.get(c.category()) * percent / 100; return List.of(new Line(percent + "% off " + minDays + "+ days", -off)); }}
// A car left at another branch has to be brought back, so a one-way rental pays a flat fee.record OneWayFee(long cents) implements PriceRule { public List<Line> apply(Charge c) { return c.pickupBranch().equals(c.returnBranch()) ? List.of() : List.of(new Line("one-way", cents)); }}
// Each day late costs more than a booked day, because the car may have been promised to the// next customer.record LateFee(int percentOfRate, Map<Category, Long> perDay) implements PriceRule { public List<Line> apply(Charge c) { if (c.lateDays() == 0) return List.of(); long cents = c.lateDays() * perDay.get(c.category()) * percentOfRate / 100; return List.of(new Line(c.lateDays() + " days late", cents)); }}
// Runs every rule and collects their lines into one bill. The order of the rules is the order// of the lines on the bill.class Pricing { private final List<PriceRule> rules;
Pricing(List<PriceRule> rules) { this.rules = rules; }
Bill bill(Charge c) { List<Line> lines = new ArrayList<>(); for (PriceRule rule : rules) lines.addAll(rule.apply(c)); return new Bill(lines); }
// The same rules, run with the plan instead of what happened. Bill quote(Reservation r) { return bill(new Charge(r.category, r.range, r.range.end(), r.branch, r.branch)); }
// The same rules, run with what really happened: the return day and branch. Bill invoice(Reservation r) { return bill(new Charge(r.category, r.range, r.returnedOn, r.branch, r.returnBranch)); }}# One line of a bill. Money is in cents, so totals add up exactly.@dataclass(frozen=True)class Line: label: str cents: int # money in cents, so totals add up exactly
# A bill keeps its lines, not just the total, so the customer sees what each charge is for.@dataclass(frozen=True)class Bill: lines: list[Line]
@property def total(self) -> int: return sum(l.cents for l in self.lines)
# What a price rule can see. Before pickup it's a quote: returned_on is the planned end and# return_branch the pickup branch. At return it's the real thing.@dataclass(frozen=True)class Charge: category: Category range: DateRange returned_on: int pickup_branch: str return_branch: str
@property def late_days(self) -> int: return max(0, self.returned_on - self.range.end)
# One rule per kind of charge. A new fee, a promotion or a different rate card is a new# rule, and the code that adds up the bill doesn't change.class PriceRule(Protocol): def apply(self, c: Charge) -> list[Line]: ...
# The base price: booked days times the category's daily rate.@dataclass(frozen=True)class DailyRate: per_day: dict[Category, int]
def apply(self, c: Charge) -> list[Line]: return [Line(f"{c.range.days} days of {c.category.name}", c.range.days * self.per_day[c.category])]
# Long rentals pay less per day: a percentage off the booked days.@dataclass(frozen=True)class LongRentalDiscount: min_days: int percent: int per_day: dict[Category, int]
def apply(self, c: Charge) -> list[Line]: if c.range.days < self.min_days: return [] off = c.range.days * self.per_day[c.category] * self.percent // 100 return [Line(f"{self.percent}% off {self.min_days}+ days", -off)]
# A car left at another branch has to be brought back, so a one-way rental pays a flat fee.@dataclass(frozen=True)class OneWayFee: cents: int
def apply(self, c: Charge) -> list[Line]: return [] if c.pickup_branch == c.return_branch else [Line("one-way", self.cents)]
# Each day late costs more than a booked day, because the car may have been promised to the# next customer.@dataclass(frozen=True)class LateFee: percent_of_rate: int per_day: dict[Category, int]
def apply(self, c: Charge) -> list[Line]: if c.late_days == 0: return [] return [Line(f"{c.late_days} days late", c.late_days * self.per_day[c.category] * self.percent_of_rate // 100)]
# Runs every rule and collects their lines into one bill. The order of the rules is the# order of the lines on the bill.class Pricing: def __init__(self, rules: list[PriceRule]): self._rules = rules
def bill(self, c: Charge) -> Bill: return Bill([line for rule in self._rules for line in rule.apply(c)])
# The same rules, run with the plan instead of what happened. def quote(self, r: Reservation) -> Bill: return self.bill(Charge(r.category, r.range, r.range.end, r.branch, r.branch))
# The same rules, run with what really happened: the return day and branch. def invoice(self, r: Reservation) -> Bill: return self.bill(Charge(r.category, r.range, r.returned_on, r.branch, r.return_branch))// Money in cents, so totals add up exactly.public sealed record Line(string Label, long Cents);
// A bill keeps its lines, not just the total, so the customer sees what each charge is for.public sealed record Bill(IReadOnlyList<Line> Lines){ public long Total => Lines.Sum(l => l.Cents);}
// What a price rule can see. Before pickup it's a quote: ReturnedOn is the planned end and// ReturnBranch the pickup branch. At return it's the real thing.public sealed record Charge(Category Category, DateRange Range, int ReturnedOn, string PickupBranch, string ReturnBranch){ public int LateDays => Math.Max(0, ReturnedOn - Range.End);}
// One rule per kind of charge. A new fee, a promotion or a different rate card is a new rule,// and the code that adds up the bill doesn't change.public interface IPriceRule{ IEnumerable<Line> Apply(Charge c);}
// The base price: booked days times the category's daily rate.public sealed record DailyRate(IReadOnlyDictionary<Category, long> PerDay) : IPriceRule{ public IEnumerable<Line> Apply(Charge c) { yield return new Line($"{c.Range.Days} days of {c.Category}", c.Range.Days * PerDay[c.Category]); }}
// Long rentals pay less per day: a percentage off the booked days.public sealed record LongRentalDiscount(int MinDays, int Percent, IReadOnlyDictionary<Category, long> PerDay) : IPriceRule{ public IEnumerable<Line> Apply(Charge c) { if (c.Range.Days < MinDays) yield break; yield return new Line($"{Percent}% off {MinDays}+ days", -(c.Range.Days * PerDay[c.Category] * Percent / 100)); }}
// A car left at another branch has to be brought back, so a one-way rental pays a flat fee.public sealed record OneWayFee(long Cents) : IPriceRule{ public IEnumerable<Line> Apply(Charge c) { if (c.PickupBranch != c.ReturnBranch) yield return new Line("one-way", Cents); }}
// Each day late costs more than a booked day, because the car may have been promised to the// next customer.public sealed record LateFee(int PercentOfRate, IReadOnlyDictionary<Category, long> PerDay) : IPriceRule{ public IEnumerable<Line> Apply(Charge c) { if (c.LateDays == 0) yield break; yield return new Line($"{c.LateDays} days late", c.LateDays * PerDay[c.Category] * PercentOfRate / 100); }}
// Runs every rule and collects their lines into one bill. The order of the rules is the order// of the lines on the bill.public sealed class Pricing{ private readonly IReadOnlyList<IPriceRule> _rules;
public Pricing(IReadOnlyList<IPriceRule> rules) => _rules = rules;
public Bill Price(Charge c) => new(_rules.SelectMany(rule => rule.Apply(c)).ToList());
// The same rules, run with the plan instead of what happened. public Bill Quote(Reservation r) => Price(new Charge(r.Category, r.Range, r.Range.End, r.Branch, r.Branch));
// The same rules, run with what really happened: the return day and branch. public Bill Invoice(Reservation r) => Price(new Charge(r.Category, r.Range, r.ReturnedOn, r.Branch, r.ReturnBranch));}// Line is one row of a bill. Money is in cents, so totals add up exactly.type Line struct { Label string Cents int64}
// Bill keeps its lines, not just the total, so the customer sees what each charge is for.type Bill struct{ Lines []Line }
func (b Bill) Total() int64 { var t int64 for _, l := range b.Lines { t += l.Cents } return t}
// Charge is what a price rule can see. Before pickup it's a quote: ReturnedOn is the planned end// and ReturnBranch the pickup branch. At return it's the real thing.type Charge struct { Category Category Range DateRange ReturnedOn int PickupBranch string ReturnBranch string}
func (c Charge) LateDays() int { return max(0, c.ReturnedOn-c.Range.End) }
// PriceRule is one kind of charge. A new fee, a promotion or a different rate card is a new// rule, and the code that adds up the bill doesn't change.type PriceRule interface { Apply(c Charge) []Line}
// DailyRate is the base price: booked days times the category's daily rate.type DailyRate struct{ PerDay map[Category]int64 }
func (r DailyRate) Apply(c Charge) []Line { days := int64(c.Range.Days()) return []Line{{fmt.Sprintf("%d days of %v", days, c.Category), days * r.PerDay[c.Category]}}}
// LongRentalDiscount takes a percentage off the booked days of a long rental.type LongRentalDiscount struct { MinDays, Percent int PerDay map[Category]int64}
func (r LongRentalDiscount) Apply(c Charge) []Line { if c.Range.Days() < r.MinDays { return nil } off := int64(c.Range.Days()) * r.PerDay[c.Category] * int64(r.Percent) / 100 return []Line{{fmt.Sprintf("%d%% off %d+ days", r.Percent, r.MinDays), -off}}}
// OneWayFee is a flat fee for a car left at another branch, which has to be brought back.type OneWayFee struct{ Cents int64 }
func (r OneWayFee) Apply(c Charge) []Line { if c.PickupBranch == c.ReturnBranch { return nil } return []Line{{"one-way", r.Cents}}}
// LateFee charges each day late at more than a booked day, because the car may have been// promised to the next customer.type LateFee struct { PercentOfRate int PerDay map[Category]int64}
func (r LateFee) Apply(c Charge) []Line { if c.LateDays() == 0 { return nil } cents := int64(c.LateDays()) * r.PerDay[c.Category] * int64(r.PercentOfRate) / 100 return []Line{{fmt.Sprintf("%d days late", c.LateDays()), cents}}}
// Pricing runs every rule and collects their lines into one bill. The order of the rules is the// order of the lines on the bill.type Pricing struct{ Rules []PriceRule }
func (p Pricing) Bill(c Charge) Bill { var lines []Line for _, rule := range p.Rules { lines = append(lines, rule.Apply(c)...) } return Bill{lines}}
// Quote runs the same rules with the plan instead of what happened.func (p Pricing) Quote(r *Reservation) Bill { return p.Bill(Charge{r.Category, r.Range, r.Range.End, r.Branch, r.Branch})}
// Invoice runs the same rules with what really happened: the return day and branch.func (p Pricing) Invoice(r *Reservation) Bill { return p.Bill(Charge{r.Category, r.Range, r.ReturnedOn, r.Branch, r.ReturnBranch})}// Money in cents, so totals add up exactly.interface Line { readonly label: string; readonly cents: number;}
// A bill keeps its lines, not just the total, so the customer sees what each charge is for.class Bill { constructor(readonly lines: Line[]) {}
get total(): number { return this.lines.reduce((sum, l) => sum + l.cents, 0); }}
// What a price rule can see. Before pickup it's a quote: returnedOn is the planned end and// returnBranch the pickup branch. At return it's the real thing.interface Charge { readonly category: Category; readonly range: DateRange; readonly returnedOn: number; readonly pickupBranch: string; readonly returnBranch: string;}
const lateDays = (c: Charge) => Math.max(0, c.returnedOn - c.range.end);
// One rule per kind of charge. A new fee, a promotion or a different rate card is a new rule, and// the code that adds up the bill doesn't change.interface PriceRule { apply(c: Charge): Line[];}
type Rates = Record<Category, number>;
// The base price: booked days times the category's daily rate.class DailyRate implements PriceRule { constructor(private readonly perDay: Rates) {}
apply(c: Charge): Line[] { return [{ label: `${c.range.days} days of ${c.category}`, cents: c.range.days * this.perDay[c.category] }]; }}
// Long rentals pay less per day: a percentage off the booked days.class LongRentalDiscount implements PriceRule { constructor( private readonly minDays: number, private readonly percent: number, private readonly perDay: Rates, ) {}
apply(c: Charge): Line[] { if (c.range.days < this.minDays) return []; const off = Math.floor((c.range.days * this.perDay[c.category] * this.percent) / 100); return [{ label: `${this.percent}% off ${this.minDays}+ days`, cents: -off }]; }}
// A car left at another branch has to be brought back, so a one-way rental pays a flat fee.class OneWayFee implements PriceRule { constructor(private readonly cents: number) {}
apply(c: Charge): Line[] { return c.pickupBranch === c.returnBranch ? [] : [{ label: 'one-way', cents: this.cents }]; }}
// Each day late costs more than a booked day, because the car may have been promised to the next// customer.class LateFee implements PriceRule { constructor( private readonly percentOfRate: number, private readonly perDay: Rates, ) {}
apply(c: Charge): Line[] { const late = lateDays(c); if (late === 0) return []; return [{ label: `${late} days late`, cents: Math.floor((late * this.perDay[c.category] * this.percentOfRate) / 100) }]; }}
// Runs every rule and collects their lines into one bill. The order of the rules is the order of// the lines on the bill.class Pricing { constructor(private readonly rules: PriceRule[]) {}
bill(c: Charge): Bill { return new Bill(this.rules.flatMap((rule) => rule.apply(c))); }
// The same rules, run with the plan instead of what happened. quote(r: Reservation): Bill { return this.bill({ category: r.category, range: r.range, returnedOn: r.range.end, pickupBranch: r.branch, returnBranch: r.branch, }); }
// The same rules, run with what really happened: the return day and branch. invoice(r: Reservation): Bill { return this.bill({ category: r.category, range: r.range, returnedOn: r.returnedOn, pickupBranch: r.branch, returnBranch: r.returnBranch, }); }}Go deeperA late, one-way rental, line by line2 min
Say an SUV costs 60.00 a day, the one-way fee is 50.00, and late days cost one and a half times the rate. Ana books days 1 to 8 at the airport: seven days. She returns on day 10, at the city branch.
The quote, at booking, assumes an on-time return to the same branch:
| Rule | Line | Amount |
|---|---|---|
DailyRate |
7 days × 60.00 | 420.00 |
LongRentalDiscount |
a week or more: 10% off 420.00 | −42.00 |
OneWayFee |
same branch: no line | |
LateFee |
on time: no line | |
| Total | 378.00 |
The invoice, after the trip, uses what happened: two days late, and a different branch.
| Rule | Line | Amount |
|---|---|---|
DailyRate |
7 days × 60.00 | 420.00 |
LongRentalDiscount |
10% off 420.00 | −42.00 |
OneWayFee |
airport to city | 50.00 |
LateFee |
2 days × 90.00 | 180.00 |
| Total | 608.00 |
The first two lines are identical in both, because the same rules made them from the same booked range. Ana can see exactly which line changed and why, which is what customers ask about when a bill is higher than the quote. (These are the same rates and fees the tests use.)
A weekend surcharge or a coupon is one more rule in the list. Pricing, the reservation and
the service don’t change.
This is enough for a mid-level answer. Bookings by category and branch, checked day by day, a reservation with states, and pricing as rules. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. Bookings is what the website, the app and the counter call. It uses
RentalService to keep cars and reservations, and Pricing to quote and invoice.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;
// One car in the fleet. Its branch changes when it is returned somewhere else.class Car { final String id; final Category category; String branch; Reservation rental; // the rental using it now, or null while it is on the lot
Car(String id, Category category, String branch) { this.id = id; this.category = category; this.branch = branch; }}
// The fleet and the bookings. Its main question: on every day of this range, is a car of this// kind still left at this branch?class RentalService { private final List<Car> cars = new ArrayList<>(); private final Map<String, Reservation> reservations = new LinkedHashMap<>(); private int nextId = 1;
void addCar(String id, Category category, String branch) { cars.add(new Car(id, category, branch)); }
// Returns null when no car is free for every day of the range. Reservation reserve(String customer, Category category, String branch, DateRange range) { if (!available(category, branch, range)) return null; Reservation r = new Reservation("R" + nextId++, customer, category, branch, range); reservations.put(r.id, r); return r; }
Result cancel(String id) { Reservation r = reservations.get(id); if (r == null) return Result.NOT_FOUND; if (r.status != Status.RESERVED) return Result.WRONG_STATE; r.status = Status.CANCELLED; return Result.OK; }
// The customer arrives on the first day. Any car of the category that is on this branch's lot // will do; this is where a reservation becomes a car. Result pickup(String id, int today) { Reservation r = reservations.get(id); if (r == null) return Result.NOT_FOUND; if (r.status != Status.RESERVED) return Result.WRONG_STATE; if (today != r.range.start()) return Result.NOT_TODAY; Car car = cars.stream() .filter(c -> c.category == r.category && c.branch.equals(r.branch) && c.rental == null) .findFirst().orElse(null); if (car == null) return Result.NO_CAR; // a car came back late, or went one way car.rental = r; r.car = car; r.status = Status.ACTIVE; return Result.OK; }
// Any day, at any branch. A car returned elsewhere now belongs to that branch's lot. Result giveBack(String id, int today, String branch) { Reservation r = reservations.get(id); if (r == null) return Result.NOT_FOUND; if (r.status != Status.ACTIVE) return Result.WRONG_STATE; r.status = Status.RETURNED; r.returnedOn = today; r.returnBranch = branch; r.car.rental = null; r.car.branch = branch; return Result.OK; }
Reservation get(String id) { return reservations.get(id); }
// For each day, compare the cars this branch has with the bookings that need one that day. One // full day is enough to say no. boolean available(Category category, String branch, DateRange range) { long fleet = cars.stream().filter(c -> c.category == category && c.branch.equals(branch)).count(); for (int day = range.start(); day < range.end(); day++) { int d = day; long booked = reservations.values().stream() .filter(r -> r.holdsACar() && r.category == category && r.branch.equals(branch) && r.range.contains(d)) .count(); if (booked >= fleet) return false; } return true; }}
// What a customer books: a kind of car, not a particular one.enum Category { ECONOMY, SUV, VAN }
// Days as numbers counted from some first day, so the arithmetic stays simple. The end is// exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental// can start that same day.record DateRange(int start, int end) { DateRange { if (end <= start) throw new IllegalArgumentException("a rental is at least one day"); }
int days() { return end - start; }
boolean contains(int day) { return day >= start && day < end; }}
// A reservation's life: booked, picked up, returned. It can be cancelled only before pickup.enum Status { RESERVED, ACTIVE, RETURNED, CANCELLED }
// A booking for a kind of car at a branch, for some days. The actual car is chosen at pickup,// so any free car of that kind will do.class Reservation { final String id; final String customer; final Category category; final String branch; final DateRange range; Status status = Status.RESERVED; Car car; // set at pickup int returnedOn; // set at return String returnBranch; // set at return; differs from branch for a one-way rental
Reservation(String id, String customer, Category category, String branch, DateRange range) { this.id = id; this.customer = customer; this.category = category; this.branch = branch; this.range = range; }
// Reserved and active rentals need a car. Cancelled and returned ones don't. boolean holdsACar() { return status == Status.RESERVED || status == Status.ACTIVE; }}
// What a request gets back. NOT_TODAY: pickup is only on the first day. NO_CAR: the booking// counted a car, but none is on the lot right now.enum Result { OK, NOT_FOUND, WRONG_STATE, NOT_TODAY, NO_CAR }
// Money in cents, so totals add up exactly.record Line(String label, long cents) {}
// A bill keeps its lines, not just the total, so the customer sees what each charge is for.record Bill(List<Line> lines) { long total() { return lines.stream().mapToLong(Line::cents).sum(); }}
// What a price rule can see. Before pickup it's a quote: returnedOn is the planned end and// returnBranch the pickup branch. At return it's the real thing.record Charge(Category category, DateRange range, int returnedOn, String pickupBranch, String returnBranch) { int daysCharged() { return Math.max(range.days(), returnedOn - range.start()); }
int lateDays() { return Math.max(0, returnedOn - range.end()); }}
// One rule per kind of charge. A new fee, a promotion or a different rate card is a new rule,// and the service that adds up the bill doesn't change.interface PriceRule { List<Line> apply(Charge c);}
// The base price: booked days times the category's daily rate.record DailyRate(Map<Category, Long> perDay) implements PriceRule { public List<Line> apply(Charge c) { return List.of(new Line(c.range().days() + " days of " + c.category(), c.range().days() * perDay.get(c.category()))); }}
// Long rentals pay less per day: a percentage off the booked days.record LongRentalDiscount(int minDays, int percent, Map<Category, Long> perDay) implements PriceRule { public List<Line> apply(Charge c) { if (c.range().days() < minDays) return List.of(); long off = c.range().days() * perDay.get(c.category()) * percent / 100; return List.of(new Line(percent + "% off " + minDays + "+ days", -off)); }}
// A car left at another branch has to be brought back, so a one-way rental pays a flat fee.record OneWayFee(long cents) implements PriceRule { public List<Line> apply(Charge c) { return c.pickupBranch().equals(c.returnBranch()) ? List.of() : List.of(new Line("one-way", cents)); }}
// Each day late costs more than a booked day, because the car may have been promised to the// next customer.record LateFee(int percentOfRate, Map<Category, Long> perDay) implements PriceRule { public List<Line> apply(Charge c) { if (c.lateDays() == 0) return List.of(); long cents = c.lateDays() * perDay.get(c.category()) * percentOfRate / 100; return List.of(new Line(c.lateDays() + " days late", cents)); }}
// Runs every rule and collects their lines into one bill. The order of the rules is the order// of the lines on the bill.class Pricing { private final List<PriceRule> rules;
Pricing(List<PriceRule> rules) { this.rules = rules; }
Bill bill(Charge c) { List<Line> lines = new ArrayList<>(); for (PriceRule rule : rules) lines.addAll(rule.apply(c)); return new Bill(lines); }
// The same rules, run with the plan instead of what happened. Bill quote(Reservation r) { return bill(new Charge(r.category, r.range, r.range.end(), r.branch, r.branch)); }
// The same rules, run with what really happened: the return day and branch. Bill invoice(Reservation r) { return bill(new Charge(r.category, r.range, r.returnedOn, r.branch, r.returnBranch)); }}
// Payment, which takes a second or two and may decline.interface Payments { boolean charge(String customer, long cents);}
// Many customers book at once, from the website, the app and the counter. Checking that a car// is free and recording the reservation must be one step, or two customers can both see the// last SUV as free and both book it. That step happens under the lock. Charging the card is// slow, so it happens outside the lock: the reservation is recorded first, which holds the car,// and cancelled if the card is declined.class Bookings { private final RentalService service; private final Pricing pricing; private final Payments payments; private final Object lock = new Object();
Bookings(RentalService service, Pricing pricing, Payments payments) { this.service = service; this.pricing = pricing; this.payments = payments; }
Reservation book(String customer, Category category, String branch, DateRange range) { Reservation r; synchronized (lock) { r = service.reserve(customer, category, branch, range); } if (r == null) return null; if (payments.charge(customer, pricing.quote(r).total())) return r; synchronized (lock) { service.cancel(r.id); } return null; }
// Pickup and return change which cars are free, so they take the same lock. Result pickup(String id, int today) { synchronized (lock) { return service.pickup(id, today); } }
Result giveBack(String id, int today, String branch) { synchronized (lock) { return service.giveBack(id, today, branch); } }
boolean available(Category category, String branch, DateRange range) { synchronized (lock) { return service.available(category, branch, range); } }}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingfrom dataclasses import dataclassfrom enum import Enum, autofrom typing import Protocol
# One car in the fleet. Its branch changes when it is returned somewhere else.class Car: def __init__(self, id: str, category: Category, branch: str): self.id = id self.category = category self.branch = branch self.rental: Reservation | None = None # the rental using it now, or None on the lot
# The fleet and the bookings. Its main question: on every day of this range, is a car of# this kind still left at this branch?class RentalService: def __init__(self): self._cars: list[Car] = [] self._reservations: dict[str, Reservation] = {} self._next_id = 1
def add_car(self, id: str, category: Category, branch: str) -> None: self._cars.append(Car(id, category, branch))
# Returns None when no car is free for every day of the range. def reserve(self, customer: str, category: Category, branch: str, dates: DateRange) -> Reservation | None: if not self.available(category, branch, dates): return None r = Reservation(f"R{self._next_id}", customer, category, branch, dates) self._next_id += 1 self._reservations[r.id] = r return r
def cancel(self, id: str) -> Result: r = self._reservations.get(id) if r is None: return Result.NOT_FOUND if r.status is not Status.RESERVED: return Result.WRONG_STATE r.status = Status.CANCELLED return Result.OK
# The customer arrives on the first day. Any car of the category on this branch's lot # will do; this is where a reservation becomes a car. def pickup(self, id: str, today: int) -> Result: r = self._reservations.get(id) if r is None: return Result.NOT_FOUND if r.status is not Status.RESERVED: return Result.WRONG_STATE if today != r.range.start: return Result.NOT_TODAY car = next( (c for c in self._cars if c.category is r.category and c.branch == r.branch and c.rental is None), None, ) if car is None: return Result.NO_CAR # a car came back late, or went one way car.rental = r r.car = car r.status = Status.ACTIVE return Result.OK
# Any day, at any branch. A car returned elsewhere now belongs to that branch's lot. def give_back(self, id: str, today: int, branch: str) -> Result: r = self._reservations.get(id) if r is None: return Result.NOT_FOUND if r.status is not Status.ACTIVE: return Result.WRONG_STATE r.status = Status.RETURNED r.returned_on = today r.return_branch = branch r.car.rental = None r.car.branch = branch return Result.OK
def get(self, id: str) -> Reservation | None: return self._reservations.get(id)
# For each day, compare the cars this branch has with the bookings that need one that # day. One full day is enough to say no. def available(self, category: Category, branch: str, dates: DateRange) -> bool: fleet = sum(1 for c in self._cars if c.category is category and c.branch == branch) for day in range(dates.start, dates.end): booked = sum( 1 for r in self._reservations.values() if r.holds_a_car() and r.category is category and r.branch == branch and r.range.contains(day) ) if booked >= fleet: return False return True
# What a customer books: a kind of car, not a particular one.class Category(Enum): ECONOMY = auto() SUV = auto() VAN = auto()
# Days as numbers counted from some first day, so the arithmetic stays simple. The end is# exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental# can start that same day.@dataclass(frozen=True)class DateRange: start: int end: int
def __post_init__(self): if self.end <= self.start: raise ValueError("a rental is at least one day")
@property def days(self) -> int: return self.end - self.start
def contains(self, day: int) -> bool: return self.start <= day < self.end
# A reservation's life: booked, picked up, returned. It can be cancelled only before pickup.class Status(Enum): RESERVED = auto() ACTIVE = auto() RETURNED = auto() CANCELLED = auto()
# A booking for a kind of car at a branch, for some days. The actual car is chosen at# pickup, so any free car of that kind will do.@dataclass(eq=False)class Reservation: id: str customer: str category: Category branch: str range: DateRange status: Status = Status.RESERVED car: Car | None = None # set at pickup returned_on: int = 0 # set at return return_branch: str = "" # set at return; differs from branch for a one-way rental
# Reserved and active rentals need a car. Cancelled and returned ones don't. def holds_a_car(self) -> bool: return self.status in (Status.RESERVED, Status.ACTIVE)
# What a request gets back. NOT_TODAY: pickup is only on the first day. NO_CAR: the booking# counted a car, but none is on the lot right now.class Result(Enum): OK = auto() NOT_FOUND = auto() WRONG_STATE = auto() NOT_TODAY = auto() NO_CAR = auto()
# One line of a bill. Money is in cents, so totals add up exactly.@dataclass(frozen=True)class Line: label: str cents: int # money in cents, so totals add up exactly
# A bill keeps its lines, not just the total, so the customer sees what each charge is for.@dataclass(frozen=True)class Bill: lines: list[Line]
@property def total(self) -> int: return sum(l.cents for l in self.lines)
# What a price rule can see. Before pickup it's a quote: returned_on is the planned end and# return_branch the pickup branch. At return it's the real thing.@dataclass(frozen=True)class Charge: category: Category range: DateRange returned_on: int pickup_branch: str return_branch: str
@property def late_days(self) -> int: return max(0, self.returned_on - self.range.end)
# One rule per kind of charge. A new fee, a promotion or a different rate card is a new# rule, and the code that adds up the bill doesn't change.class PriceRule(Protocol): def apply(self, c: Charge) -> list[Line]: ...
# The base price: booked days times the category's daily rate.@dataclass(frozen=True)class DailyRate: per_day: dict[Category, int]
def apply(self, c: Charge) -> list[Line]: return [Line(f"{c.range.days} days of {c.category.name}", c.range.days * self.per_day[c.category])]
# Long rentals pay less per day: a percentage off the booked days.@dataclass(frozen=True)class LongRentalDiscount: min_days: int percent: int per_day: dict[Category, int]
def apply(self, c: Charge) -> list[Line]: if c.range.days < self.min_days: return [] off = c.range.days * self.per_day[c.category] * self.percent // 100 return [Line(f"{self.percent}% off {self.min_days}+ days", -off)]
# A car left at another branch has to be brought back, so a one-way rental pays a flat fee.@dataclass(frozen=True)class OneWayFee: cents: int
def apply(self, c: Charge) -> list[Line]: return [] if c.pickup_branch == c.return_branch else [Line("one-way", self.cents)]
# Each day late costs more than a booked day, because the car may have been promised to the# next customer.@dataclass(frozen=True)class LateFee: percent_of_rate: int per_day: dict[Category, int]
def apply(self, c: Charge) -> list[Line]: if c.late_days == 0: return [] return [Line(f"{c.late_days} days late", c.late_days * self.per_day[c.category] * self.percent_of_rate // 100)]
# Runs every rule and collects their lines into one bill. The order of the rules is the# order of the lines on the bill.class Pricing: def __init__(self, rules: list[PriceRule]): self._rules = rules
def bill(self, c: Charge) -> Bill: return Bill([line for rule in self._rules for line in rule.apply(c)])
# The same rules, run with the plan instead of what happened. def quote(self, r: Reservation) -> Bill: return self.bill(Charge(r.category, r.range, r.range.end, r.branch, r.branch))
# The same rules, run with what really happened: the return day and branch. def invoice(self, r: Reservation) -> Bill: return self.bill(Charge(r.category, r.range, r.returned_on, r.branch, r.return_branch))
# Payment, which takes a second or two and may decline.class Payments(Protocol): def charge(self, customer: str, cents: int) -> bool: ...
# Many customers book at once, from the website, the app and the counter. Checking that a# car is free and recording the reservation must be one step, or two customers can both see# the last SUV as free and both book it. That step happens under the lock. Charging the card# is slow, so it happens outside the lock: the reservation is recorded first, which holds the# car, and cancelled if the card is declined.class Bookings: def __init__(self, service: RentalService, pricing: Pricing, payments: Payments): self._service = service self._pricing = pricing self._payments = payments self._lock = threading.Lock()
def book(self, customer: str, category: Category, branch: str, dates: DateRange) -> Reservation | None: with self._lock: r = self._service.reserve(customer, category, branch, dates) if r is None: return None if self._payments.charge(customer, self._pricing.quote(r).total): return r with self._lock: self._service.cancel(r.id) return None
# Pickup and return change which cars are free, so they take the same lock. def pickup(self, id: str, today: int) -> Result: with self._lock: return self._service.pickup(id, today)
def give_back(self, id: str, today: int, branch: str) -> Result: with self._lock: return self._service.give_back(id, today, branch)
def available(self, category: Category, branch: str, dates: DateRange) -> bool: with self._lock: return self._service.available(category, branch, dates)// The complete design, built from every stage on this page.
// One car in the fleet. Its branch changes when it is returned somewhere else.public sealed class Car{ public Car(string id, Category category, string branch) => (Id, Category, Branch) = (id, category, branch);
public string Id { get; } public Category Category { get; } public string Branch { get; set; } public Reservation? Rental { get; set; } // the rental using it now, or null on the lot}
// The fleet and the bookings. Its main question: on every day of this range, is a car of this// kind still left at this branch?public sealed class RentalService{ private readonly List<Car> _cars = new(); private readonly Dictionary<string, Reservation> _reservations = new(); private int _nextId = 1;
public void AddCar(string id, Category category, string branch) => _cars.Add(new Car(id, category, branch));
// Returns null when no car is free for every day of the range. public Reservation? Reserve(string customer, Category category, string branch, DateRange range) { if (!Available(category, branch, range)) return null; var r = new Reservation($"R{_nextId++}", customer, category, branch, range); _reservations[r.Id] = r; return r; }
public Result Cancel(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NotFound; if (r.Status != Status.Reserved) return Result.WrongState; r.Status = Status.Cancelled; return Result.Ok; }
// The customer arrives on the first day. Any car of the category on this branch's lot will // do; this is where a reservation becomes a car. public Result Pickup(string id, int today) { if (!_reservations.TryGetValue(id, out var r)) return Result.NotFound; if (r.Status != Status.Reserved) return Result.WrongState; if (today != r.Range.Start) return Result.NotToday; var car = _cars.FirstOrDefault(c => c.Category == r.Category && c.Branch == r.Branch && c.Rental is null); if (car is null) return Result.NoCar; // a car came back late, or went one way car.Rental = r; r.Car = car; r.Status = Status.Active; return Result.Ok; }
// Any day, at any branch. A car returned elsewhere now belongs to that branch's lot. public Result GiveBack(string id, int today, string branch) { if (!_reservations.TryGetValue(id, out var r)) return Result.NotFound; if (r.Status != Status.Active) return Result.WrongState; r.Status = Status.Returned; r.ReturnedOn = today; r.ReturnBranch = branch; r.Car!.Rental = null; r.Car.Branch = branch; return Result.Ok; }
public Reservation? Get(string id) => _reservations.GetValueOrDefault(id);
// For each day, compare the cars this branch has with the bookings that need one that day. One // full day is enough to say no. public bool Available(Category category, string branch, DateRange range) { int fleet = _cars.Count(c => c.Category == category && c.Branch == branch); for (int day = range.Start; day < range.End; day++) { int booked = _reservations.Values.Count(r => r.HoldsACar && r.Category == category && r.Branch == branch && r.Range.Contains(day)); if (booked >= fleet) return false; } return true; }}
// What a customer books: a kind of car, not a particular one.public enum Category { Economy, Suv, Van }
// Days as numbers counted from some first day, so the arithmetic stays simple. The end is// exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental// can start that same day.public readonly record struct DateRange{ public DateRange(int start, int end) { if (end <= start) throw new ArgumentException("a rental is at least one day"); Start = start; End = end; }
public int Start { get; } public int End { get; } public int Days => End - Start; public bool Contains(int day) => day >= Start && day < End;}
// A reservation's life: booked, picked up, returned. It can be cancelled only before pickup.public enum Status { Reserved, Active, Returned, Cancelled }
// A booking for a kind of car at a branch, for some days. The actual car is chosen at pickup,// so any free car of that kind will do.public sealed class Reservation{ public Reservation(string id, string customer, Category category, string branch, DateRange range) => (Id, Customer, Category, Branch, Range) = (id, customer, category, branch, range);
public string Id { get; } public string Customer { get; } public Category Category { get; } public string Branch { get; } public DateRange Range { get; } public Status Status { get; set; } = Status.Reserved; public Car? Car { get; set; } // set at pickup public int ReturnedOn { get; set; } // set at return public string ReturnBranch { get; set; } = ""; // set at return; differs for a one-way rental // Reserved and active rentals need a car. Cancelled and returned ones don't. public bool HoldsACar => Status is Status.Reserved or Status.Active;}
// What a request gets back. NotToday: pickup is only on the first day. NoCar: the booking// counted a car, but none is on the lot right now.public enum Result { Ok, NotFound, WrongState, NotToday, NoCar }
// Money in cents, so totals add up exactly.public sealed record Line(string Label, long Cents);
// A bill keeps its lines, not just the total, so the customer sees what each charge is for.public sealed record Bill(IReadOnlyList<Line> Lines){ public long Total => Lines.Sum(l => l.Cents);}
// What a price rule can see. Before pickup it's a quote: ReturnedOn is the planned end and// ReturnBranch the pickup branch. At return it's the real thing.public sealed record Charge(Category Category, DateRange Range, int ReturnedOn, string PickupBranch, string ReturnBranch){ public int LateDays => Math.Max(0, ReturnedOn - Range.End);}
// One rule per kind of charge. A new fee, a promotion or a different rate card is a new rule,// and the code that adds up the bill doesn't change.public interface IPriceRule{ IEnumerable<Line> Apply(Charge c);}
// The base price: booked days times the category's daily rate.public sealed record DailyRate(IReadOnlyDictionary<Category, long> PerDay) : IPriceRule{ public IEnumerable<Line> Apply(Charge c) { yield return new Line($"{c.Range.Days} days of {c.Category}", c.Range.Days * PerDay[c.Category]); }}
// Long rentals pay less per day: a percentage off the booked days.public sealed record LongRentalDiscount(int MinDays, int Percent, IReadOnlyDictionary<Category, long> PerDay) : IPriceRule{ public IEnumerable<Line> Apply(Charge c) { if (c.Range.Days < MinDays) yield break; yield return new Line($"{Percent}% off {MinDays}+ days", -(c.Range.Days * PerDay[c.Category] * Percent / 100)); }}
// A car left at another branch has to be brought back, so a one-way rental pays a flat fee.public sealed record OneWayFee(long Cents) : IPriceRule{ public IEnumerable<Line> Apply(Charge c) { if (c.PickupBranch != c.ReturnBranch) yield return new Line("one-way", Cents); }}
// Each day late costs more than a booked day, because the car may have been promised to the// next customer.public sealed record LateFee(int PercentOfRate, IReadOnlyDictionary<Category, long> PerDay) : IPriceRule{ public IEnumerable<Line> Apply(Charge c) { if (c.LateDays == 0) yield break; yield return new Line($"{c.LateDays} days late", c.LateDays * PerDay[c.Category] * PercentOfRate / 100); }}
// Runs every rule and collects their lines into one bill. The order of the rules is the order// of the lines on the bill.public sealed class Pricing{ private readonly IReadOnlyList<IPriceRule> _rules;
public Pricing(IReadOnlyList<IPriceRule> rules) => _rules = rules;
public Bill Price(Charge c) => new(_rules.SelectMany(rule => rule.Apply(c)).ToList());
// The same rules, run with the plan instead of what happened. public Bill Quote(Reservation r) => Price(new Charge(r.Category, r.Range, r.Range.End, r.Branch, r.Branch));
// The same rules, run with what really happened: the return day and branch. public Bill Invoice(Reservation r) => Price(new Charge(r.Category, r.Range, r.ReturnedOn, r.Branch, r.ReturnBranch));}
// Payment, which takes a second or two and may decline.public interface IPayments{ bool Charge(string customer, long cents);}
// Many customers book at once, from the website, the app and the counter. Checking that a car// is free and recording the reservation must be one step, or two customers can both see the// last SUV as free and both book it. That step happens under the lock. Charging the card is// slow, so it happens outside the lock: the reservation is recorded first, which holds the car,// and cancelled if the card is declined.public sealed class Bookings{ private readonly object _lock = new(); private readonly RentalService _service; private readonly Pricing _pricing; private readonly IPayments _payments;
public Bookings(RentalService service, Pricing pricing, IPayments payments) => (_service, _pricing, _payments) = (service, pricing, payments);
public Reservation? Book(string customer, Category category, string branch, DateRange range) { Reservation? r; lock (_lock) r = _service.Reserve(customer, category, branch, range); if (r is null) return null; if (_payments.Charge(customer, _pricing.Quote(r).Total)) return r; lock (_lock) _service.Cancel(r.Id); return null; }
// Pickup and return change which cars are free, so they take the same lock. public Result Pickup(string id, int today) { lock (_lock) return _service.Pickup(id, today); }
public Result GiveBack(string id, int today, string branch) { lock (_lock) return _service.GiveBack(id, today, branch); }
public bool Available(Category category, string branch, DateRange range) { lock (_lock) return _service.Available(category, branch, range); }}// The complete design, built from every stage on this page.
package carrental
import ( "fmt" "sync")
// Category is what a customer books: a kind of car, not a particular one.type Category int
const ( Economy Category = iota SUV Van)
func (c Category) String() string { return [...]string{"economy", "SUV", "van"}[c] }
// DateRange counts days as numbers from some first day, so the arithmetic stays simple. End is// exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental can// start that same day. Callers make sure End > Start.type DateRange struct{ Start, End int }
func (d DateRange) Days() int { return d.End - d.Start }
func (d DateRange) Contains(day int) bool { return day >= d.Start && day < d.End }
// RentalService holds the fleet and the bookings. Its main question: on every day of this// range, is a car of this kind still left at this branch?type RentalService struct { cars []*Car reservations map[string]*Reservation nextID int}
func NewRentalService() *RentalService { return &RentalService{reservations: map[string]*Reservation{}, nextID: 1}}
func (s *RentalService) AddCar(id string, c Category, branch string) { s.cars = append(s.cars, &Car{ID: id, Category: c, Branch: branch})}
// Reserve returns nil when every car of that category at that branch is taken on some day of// the range.func (s *RentalService) Reserve(customer string, c Category, branch string, dates DateRange) *Reservation { if !s.Available(c, branch, dates) { return nil } r := &Reservation{ID: fmt.Sprintf("R%d", s.nextID), Customer: customer, Category: c, Branch: branch, Range: dates} s.nextID++ s.reservations[r.ID] = r return r}
func (s *RentalService) Get(id string) *Reservation { return s.reservations[id] }
// Available means free on every day of the range: for each day, fewer reservations than cars.// Counting every reservation that overlaps the range would be wrong: two that overlap it on// different days need only one car between them.func (s *RentalService) Available(c Category, branch string, dates DateRange) bool { fleet := 0 for _, car := range s.cars { if car.Category == c && car.Branch == branch { fleet++ } } for day := dates.Start; day < dates.End; day++ { booked := 0 for _, r := range s.reservations { if r.HoldsACar() && r.Category == c && r.Branch == branch && r.Range.Contains(day) { booked++ } } if booked >= fleet { return false } } return true}
// Status is a reservation's life: booked, picked up, returned. It can be cancelled only before// pickup.type Status int
const ( Reserved Status = iota Active Returned Cancelled)
// Result is what a request gets back. NotToday: pickup is only on the first day. NoCar: the// booking counted a car, but none is on the lot right now.type Result int
const ( OK Result = iota NotFound WrongState NotToday NoCar)
// Car is one car in the fleet. Its branch changes when it is returned somewhere else.type Car struct { ID string Category Category Branch string Rental *Reservation // the rental using it now, or nil while it is on the lot}
// Reservation is a booking for a kind of car at a branch, for some days. The actual car is// chosen at pickup, so any free car of that kind will do.type Reservation struct { ID string Customer string Category Category Branch string Range DateRange Status Status Car *Car // set at pickup ReturnedOn int // set at return ReturnBranch string // set at return; differs from Branch for a one-way rental}
// HoldsACar is true for reserved and active rentals. Cancelled and returned ones need no car.func (r *Reservation) HoldsACar() bool { return r.Status == Reserved || r.Status == Active }
func (s *RentalService) Cancel(id string) Result { r, ok := s.reservations[id] if !ok { return NotFound } if r.Status != Reserved { return WrongState } r.Status = Cancelled return OK}
// Pickup happens on the first day. Any car of the category on this branch's lot will do; this// is where a reservation becomes a car.func (s *RentalService) Pickup(id string, today int) Result { r, ok := s.reservations[id] if !ok { return NotFound } if r.Status != Reserved { return WrongState } if today != r.Range.Start { return NotToday } for _, car := range s.cars { if car.Category == r.Category && car.Branch == r.Branch && car.Rental == nil { car.Rental = r r.Car = car r.Status = Active return OK } } return NoCar // a car came back late, or went one way}
// GiveBack works on any day, at any branch. A car returned elsewhere now belongs to that// branch's lot.func (s *RentalService) GiveBack(id string, today int, branch string) Result { r, ok := s.reservations[id] if !ok { return NotFound } if r.Status != Active { return WrongState } r.Status = Returned r.ReturnedOn = today r.ReturnBranch = branch r.Car.Rental = nil r.Car.Branch = branch return OK}
// Line is one row of a bill. Money is in cents, so totals add up exactly.type Line struct { Label string Cents int64}
// Bill keeps its lines, not just the total, so the customer sees what each charge is for.type Bill struct{ Lines []Line }
func (b Bill) Total() int64 { var t int64 for _, l := range b.Lines { t += l.Cents } return t}
// Charge is what a price rule can see. Before pickup it's a quote: ReturnedOn is the planned end// and ReturnBranch the pickup branch. At return it's the real thing.type Charge struct { Category Category Range DateRange ReturnedOn int PickupBranch string ReturnBranch string}
func (c Charge) LateDays() int { return max(0, c.ReturnedOn-c.Range.End) }
// PriceRule is one kind of charge. A new fee, a promotion or a different rate card is a new// rule, and the code that adds up the bill doesn't change.type PriceRule interface { Apply(c Charge) []Line}
// DailyRate is the base price: booked days times the category's daily rate.type DailyRate struct{ PerDay map[Category]int64 }
func (r DailyRate) Apply(c Charge) []Line { days := int64(c.Range.Days()) return []Line{{fmt.Sprintf("%d days of %v", days, c.Category), days * r.PerDay[c.Category]}}}
// LongRentalDiscount takes a percentage off the booked days of a long rental.type LongRentalDiscount struct { MinDays, Percent int PerDay map[Category]int64}
func (r LongRentalDiscount) Apply(c Charge) []Line { if c.Range.Days() < r.MinDays { return nil } off := int64(c.Range.Days()) * r.PerDay[c.Category] * int64(r.Percent) / 100 return []Line{{fmt.Sprintf("%d%% off %d+ days", r.Percent, r.MinDays), -off}}}
// OneWayFee is a flat fee for a car left at another branch, which has to be brought back.type OneWayFee struct{ Cents int64 }
func (r OneWayFee) Apply(c Charge) []Line { if c.PickupBranch == c.ReturnBranch { return nil } return []Line{{"one-way", r.Cents}}}
// LateFee charges each day late at more than a booked day, because the car may have been// promised to the next customer.type LateFee struct { PercentOfRate int PerDay map[Category]int64}
func (r LateFee) Apply(c Charge) []Line { if c.LateDays() == 0 { return nil } cents := int64(c.LateDays()) * r.PerDay[c.Category] * int64(r.PercentOfRate) / 100 return []Line{{fmt.Sprintf("%d days late", c.LateDays()), cents}}}
// Pricing runs every rule and collects their lines into one bill. The order of the rules is the// order of the lines on the bill.type Pricing struct{ Rules []PriceRule }
func (p Pricing) Bill(c Charge) Bill { var lines []Line for _, rule := range p.Rules { lines = append(lines, rule.Apply(c)...) } return Bill{lines}}
// Quote runs the same rules with the plan instead of what happened.func (p Pricing) Quote(r *Reservation) Bill { return p.Bill(Charge{r.Category, r.Range, r.Range.End, r.Branch, r.Branch})}
// Invoice runs the same rules with what really happened: the return day and branch.func (p Pricing) Invoice(r *Reservation) Bill { return p.Bill(Charge{r.Category, r.Range, r.ReturnedOn, r.Branch, r.ReturnBranch})}
// Payments takes a second or two and may decline.type Payments interface { Charge(customer string, cents int64) bool}
// Bookings is used by many customers at once, from the website, the app and the counter.// Checking that a car is free and recording the reservation must be one step, or two customers// can both see the last SUV as free and both book it. That step happens under the mutex.// Charging the card is slow, so it happens outside: the reservation is recorded first, which// holds the car, and cancelled if the card is declined.type Bookings struct { mu sync.Mutex service *RentalService pricing Pricing payments Payments}
func NewBookings(s *RentalService, p Pricing, pay Payments) *Bookings { return &Bookings{service: s, pricing: p, payments: pay}}
func (b *Bookings) Book(customer string, c Category, branch string, dates DateRange) *Reservation { b.mu.Lock() r := b.service.Reserve(customer, c, branch, dates) b.mu.Unlock() if r == nil { return nil } if b.payments.Charge(customer, b.pricing.Quote(r).Total()) { return r } b.mu.Lock() defer b.mu.Unlock() b.service.Cancel(r.ID) return nil}
// Pickup and GiveBack change which cars are free, so they take the same mutex.func (b *Bookings) Pickup(id string, today int) Result { b.mu.Lock() defer b.mu.Unlock() return b.service.Pickup(id, today)}
func (b *Bookings) GiveBack(id string, today int, branch string) Result { b.mu.Lock() defer b.mu.Unlock() return b.service.GiveBack(id, today, branch)}
func (b *Bookings) Available(c Category, branch string, dates DateRange) bool { b.mu.Lock() defer b.mu.Unlock() return b.service.Available(c, branch, dates)}// The complete design, built from every stage on this page.
// One car in the fleet. Its branch changes when it is returned somewhere else.class Car { rental: Reservation | null = null; // the rental using it now, or null while it is on the lot
constructor( readonly id: string, readonly category: Category, public branch: string, ) {}}
// The fleet and the bookings. Its main question: on every day of this range, is a car of this// kind still left at this branch?class RentalService { private readonly cars: Car[] = []; private readonly reservations = new Map<string, Reservation>(); private nextId = 1;
addCar(id: string, category: Category, branch: string): void { this.cars.push(new Car(id, category, branch)); }
// Returns null when no car is free for every day of the range. reserve(customer: string, category: Category, branch: string, range: DateRange): Reservation | null { if (!this.available(category, branch, range)) return null; const r = new Reservation(`R${this.nextId++}`, customer, category, branch, range); this.reservations.set(r.id, r); return r; }
cancel(id: string): Result { const r = this.reservations.get(id); if (!r) return 'not-found'; if (r.status !== 'reserved') return 'wrong-state'; r.status = 'cancelled'; return 'ok'; }
// The customer arrives on the first day. Any car of the category on this branch's lot will do; // this is where a reservation becomes a car. pickup(id: string, today: number): Result { const r = this.reservations.get(id); if (!r) return 'not-found'; if (r.status !== 'reserved') return 'wrong-state'; if (today !== r.range.start) return 'not-today'; const car = this.cars.find((c) => c.category === r.category && c.branch === r.branch && c.rental === null); if (!car) return 'no-car'; // a car came back late, or went one way car.rental = r; r.car = car; r.status = 'active'; return 'ok'; }
// Any day, at any branch. A car returned elsewhere now belongs to that branch's lot. giveBack(id: string, today: number, branch: string): Result { const r = this.reservations.get(id); if (!r) return 'not-found'; if (r.status !== 'active') return 'wrong-state'; r.status = 'returned'; r.returnedOn = today; r.returnBranch = branch; r.car!.rental = null; r.car!.branch = branch; return 'ok'; }
get(id: string): Reservation | undefined { return this.reservations.get(id); }
// For each day, compare the cars this branch has with the bookings that need one that day. One // full day is enough to say no. available(category: Category, branch: string, range: DateRange): boolean { const fleet = this.cars.filter((c) => c.category === category && c.branch === branch).length; for (let day = range.start; day < range.end; day++) { let booked = 0; for (const r of this.reservations.values()) { if (r.holdsACar() && r.category === category && r.branch === branch && r.range.contains(day)) booked++; } if (booked >= fleet) return false; } return true; }}
// What a customer books: a kind of car, not a particular one.type Category = 'economy' | 'suv' | 'van';
// Days as numbers counted from some first day, so the arithmetic stays simple. The end is// exclusive: [3, 5) is a two-day rental that hands the car back on day 5, and another rental can// start that same day.class DateRange { constructor( readonly start: number, readonly end: number, ) { if (end <= start) throw new Error('a rental is at least one day'); }
get days(): number { return this.end - this.start; }
contains(day: number): boolean { return day >= this.start && day < this.end; }}
// A reservation's life: booked, picked up, returned. It can be cancelled only before pickup.type Status = 'reserved' | 'active' | 'returned' | 'cancelled';
// A booking for a kind of car at a branch, for some days. The actual car is chosen at pickup, so// any free car of that kind will do.class Reservation { status: Status = 'reserved'; car: Car | null = null; // set at pickup returnedOn = 0; // set at return returnBranch = ''; // set at return; differs from branch for a one-way rental
constructor( readonly id: string, readonly customer: string, readonly category: Category, readonly branch: string, readonly range: DateRange, ) {}
// Reserved and active rentals need a car. Cancelled and returned ones don't. holdsACar(): boolean { return this.status === 'reserved' || this.status === 'active'; }}
// What a request gets back. not-today: pickup is only on the first day. no-car: the booking// counted a car, but none is on the lot right now.type Result = 'ok' | 'not-found' | 'wrong-state' | 'not-today' | 'no-car';
// Money in cents, so totals add up exactly.interface Line { readonly label: string; readonly cents: number;}
// A bill keeps its lines, not just the total, so the customer sees what each charge is for.class Bill { constructor(readonly lines: Line[]) {}
get total(): number { return this.lines.reduce((sum, l) => sum + l.cents, 0); }}
// What a price rule can see. Before pickup it's a quote: returnedOn is the planned end and// returnBranch the pickup branch. At return it's the real thing.interface Charge { readonly category: Category; readonly range: DateRange; readonly returnedOn: number; readonly pickupBranch: string; readonly returnBranch: string;}
const lateDays = (c: Charge) => Math.max(0, c.returnedOn - c.range.end);
// One rule per kind of charge. A new fee, a promotion or a different rate card is a new rule, and// the code that adds up the bill doesn't change.interface PriceRule { apply(c: Charge): Line[];}
type Rates = Record<Category, number>;
// The base price: booked days times the category's daily rate.class DailyRate implements PriceRule { constructor(private readonly perDay: Rates) {}
apply(c: Charge): Line[] { return [{ label: `${c.range.days} days of ${c.category}`, cents: c.range.days * this.perDay[c.category] }]; }}
// Long rentals pay less per day: a percentage off the booked days.class LongRentalDiscount implements PriceRule { constructor( private readonly minDays: number, private readonly percent: number, private readonly perDay: Rates, ) {}
apply(c: Charge): Line[] { if (c.range.days < this.minDays) return []; const off = Math.floor((c.range.days * this.perDay[c.category] * this.percent) / 100); return [{ label: `${this.percent}% off ${this.minDays}+ days`, cents: -off }]; }}
// A car left at another branch has to be brought back, so a one-way rental pays a flat fee.class OneWayFee implements PriceRule { constructor(private readonly cents: number) {}
apply(c: Charge): Line[] { return c.pickupBranch === c.returnBranch ? [] : [{ label: 'one-way', cents: this.cents }]; }}
// Each day late costs more than a booked day, because the car may have been promised to the next// customer.class LateFee implements PriceRule { constructor( private readonly percentOfRate: number, private readonly perDay: Rates, ) {}
apply(c: Charge): Line[] { const late = lateDays(c); if (late === 0) return []; return [{ label: `${late} days late`, cents: Math.floor((late * this.perDay[c.category] * this.percentOfRate) / 100) }]; }}
// Runs every rule and collects their lines into one bill. The order of the rules is the order of// the lines on the bill.class Pricing { constructor(private readonly rules: PriceRule[]) {}
bill(c: Charge): Bill { return new Bill(this.rules.flatMap((rule) => rule.apply(c))); }
// The same rules, run with the plan instead of what happened. quote(r: Reservation): Bill { return this.bill({ category: r.category, range: r.range, returnedOn: r.range.end, pickupBranch: r.branch, returnBranch: r.branch, }); }
// The same rules, run with what really happened: the return day and branch. invoice(r: Reservation): Bill { return this.bill({ category: r.category, range: r.range, returnedOn: r.returnedOn, pickupBranch: r.branch, returnBranch: r.returnBranch, }); }}
// Payment, which takes a second or two and may decline.interface Payments { charge(customer: string, cents: number): Promise<boolean>;}
// Many customers book at once, from the website, the app and the counter. Checking that a car is// free and recording the reservation must be one step, or two customers can both see the last SUV// as free and both book it.//// JavaScript runs one callback at a time, so there is no lock. What makes it one step is that// reserve runs with no await inside it. Charging the card awaits, so it comes after: the// reservation is recorded first, which holds the car, and cancelled if the card is declined.class Bookings { constructor( private readonly service: RentalService, private readonly pricing: Pricing, private readonly payments: Payments, ) {}
async book(customer: string, category: Category, branch: string, range: DateRange): Promise<Reservation | null> { const r = this.service.reserve(customer, category, branch, range); if (!r) return null; if (await this.payments.charge(customer, this.pricing.quote(r).total)) return r; this.service.cancel(r.id); return null; }
pickup(id: string, today: number): Result { return this.service.pickup(id, today); }
giveBack(id: string, today: number, branch: string): Result { return this.service.giveBack(id, today, branch); }
available(category: Category, branch: string, range: DateRange): boolean { return this.service.available(category, branch, range); }}Three ideas hold it together:
- Promises are counts, and cars are chosen late. Booking checks numbers per day. A real car is picked only at the counter.
- The reservation records facts, not money. Pricing reads the facts, so the quote and the invoice come from the same rules (Strategy).
- Every change to a reservation checks its state (State). A booking can’t be picked up
twice, cancelled while the car is out, or returned before pickup. Callers book and pay in one
call, through
Bookings(Facade).
Stress it
Section titled “Stress it”So far, one booking happens at a time. Now the website, the app and the counter all book at once.
Two customers, one SUV left
Section titled “Two customers, one SUV left”Bookings come from the website, the app and the counter at the same time. Two customers try to book the last SUV for the same weekend. What happens?
Whether you see the check-then-act race, and keep slow work out of the lock. The red flag is holding a lock while calling the payment provider.
reserve checks availability, then records the booking. Picture two requests:
- Request A checks: one SUV free.
- Request B checks: one SUV free.
- A records its booking. B records its booking.
The branch has now promised two cars it doesn’t have. This is a check-then-act race: the check was true when it ran, but not when the action happened.
The clearly correct fix: checking and recording happen as one step, under one lock. Then B’s check runs after A’s booking is recorded, and B sees no SUV free.
The better fix keeps payment out of the lock. Charging a card takes a second or two, and can be declined. Holding the lock while it runs would make every other booking, at every branch, wait. So the booking is recorded first, under the lock, which holds the car. Then the card is charged after, with no lock. If the card is declined, the booking is cancelled, under the lock again, and the car is free for the next customer.
- Color key
- Interface
- Facade
| Option | How | Overbooks? | Cost |
|---|---|---|---|
| 1. Lock, record, then pay outside the lock (our choice) | Hold the car, then charge | No | A declined card holds a car for a second |
| 2. Lock held while paying | Simplest to reason about | No | Every booking waits on the card network |
| 3. Pay first, then book | Charge, then try to reserve | No | A refund when the car is gone by then |
| 4. A lock per branch and category | Bookings for different branches don’t wait | No | More locks; needed only at large volume |
| 5. A database rule | A row per car per day, unique; insert fails when full | No | The real answer at scale; see below |
Go deeperWhy not charge the card first?1 min
Charging first sounds safer: no money, no booking. But think about the last SUV again. Both customers’ cards are charged, which takes a second or two each. Then both try to book, and one finds the SUV gone. Now we must refund a customer who was charged for nothing, and refunds take days to reach their bank. That’s a worse experience than a card being declined.
Holding the car first flips it around. Only a customer who has a car is ever charged. If their card is declined, they lose nothing, and the car goes back into the count a second later. The order of the two steps is a design decision, and “hold, then charge” is the one that keeps the bad case cheap.
// Payment, which takes a second or two and may decline.interface Payments { boolean charge(String customer, long cents);}
// Many customers book at once, from the website, the app and the counter. Checking that a car// is free and recording the reservation must be one step, or two customers can both see the// last SUV as free and both book it. That step happens under the lock. Charging the card is// slow, so it happens outside the lock: the reservation is recorded first, which holds the car,// and cancelled if the card is declined.class Bookings { private final RentalService service; private final Pricing pricing; private final Payments payments; private final Object lock = new Object();
Bookings(RentalService service, Pricing pricing, Payments payments) { this.service = service; this.pricing = pricing; this.payments = payments; }
Reservation book(String customer, Category category, String branch, DateRange range) { Reservation r; synchronized (lock) { r = service.reserve(customer, category, branch, range); } if (r == null) return null; if (payments.charge(customer, pricing.quote(r).total())) return r; synchronized (lock) { service.cancel(r.id); } return null; }
// Pickup and return change which cars are free, so they take the same lock. Result pickup(String id, int today) { synchronized (lock) { return service.pickup(id, today); } }
Result giveBack(String id, int today, String branch) { synchronized (lock) { return service.giveBack(id, today, branch); } }
boolean available(Category category, String branch, DateRange range) { synchronized (lock) { return service.available(category, branch, range); } }}# Payment, which takes a second or two and may decline.class Payments(Protocol): def charge(self, customer: str, cents: int) -> bool: ...
# Many customers book at once, from the website, the app and the counter. Checking that a# car is free and recording the reservation must be one step, or two customers can both see# the last SUV as free and both book it. That step happens under the lock. Charging the card# is slow, so it happens outside the lock: the reservation is recorded first, which holds the# car, and cancelled if the card is declined.class Bookings: def __init__(self, service: RentalService, pricing: Pricing, payments: Payments): self._service = service self._pricing = pricing self._payments = payments self._lock = threading.Lock()
def book(self, customer: str, category: Category, branch: str, dates: DateRange) -> Reservation | None: with self._lock: r = self._service.reserve(customer, category, branch, dates) if r is None: return None if self._payments.charge(customer, self._pricing.quote(r).total): return r with self._lock: self._service.cancel(r.id) return None
# Pickup and return change which cars are free, so they take the same lock. def pickup(self, id: str, today: int) -> Result: with self._lock: return self._service.pickup(id, today)
def give_back(self, id: str, today: int, branch: str) -> Result: with self._lock: return self._service.give_back(id, today, branch)
def available(self, category: Category, branch: str, dates: DateRange) -> bool: with self._lock: return self._service.available(category, branch, dates)// Payment, which takes a second or two and may decline.public interface IPayments{ bool Charge(string customer, long cents);}
// Many customers book at once, from the website, the app and the counter. Checking that a car// is free and recording the reservation must be one step, or two customers can both see the// last SUV as free and both book it. That step happens under the lock. Charging the card is// slow, so it happens outside the lock: the reservation is recorded first, which holds the car,// and cancelled if the card is declined.public sealed class Bookings{ private readonly object _lock = new(); private readonly RentalService _service; private readonly Pricing _pricing; private readonly IPayments _payments;
public Bookings(RentalService service, Pricing pricing, IPayments payments) => (_service, _pricing, _payments) = (service, pricing, payments);
public Reservation? Book(string customer, Category category, string branch, DateRange range) { Reservation? r; lock (_lock) r = _service.Reserve(customer, category, branch, range); if (r is null) return null; if (_payments.Charge(customer, _pricing.Quote(r).Total)) return r; lock (_lock) _service.Cancel(r.Id); return null; }
// Pickup and return change which cars are free, so they take the same lock. public Result Pickup(string id, int today) { lock (_lock) return _service.Pickup(id, today); }
public Result GiveBack(string id, int today, string branch) { lock (_lock) return _service.GiveBack(id, today, branch); }
public bool Available(Category category, string branch, DateRange range) { lock (_lock) return _service.Available(category, branch, range); }}// Payments takes a second or two and may decline.type Payments interface { Charge(customer string, cents int64) bool}
// Bookings is used by many customers at once, from the website, the app and the counter.// Checking that a car is free and recording the reservation must be one step, or two customers// can both see the last SUV as free and both book it. That step happens under the mutex.// Charging the card is slow, so it happens outside: the reservation is recorded first, which// holds the car, and cancelled if the card is declined.type Bookings struct { mu sync.Mutex service *RentalService pricing Pricing payments Payments}
func NewBookings(s *RentalService, p Pricing, pay Payments) *Bookings { return &Bookings{service: s, pricing: p, payments: pay}}
func (b *Bookings) Book(customer string, c Category, branch string, dates DateRange) *Reservation { b.mu.Lock() r := b.service.Reserve(customer, c, branch, dates) b.mu.Unlock() if r == nil { return nil } if b.payments.Charge(customer, b.pricing.Quote(r).Total()) { return r } b.mu.Lock() defer b.mu.Unlock() b.service.Cancel(r.ID) return nil}
// Pickup and GiveBack change which cars are free, so they take the same mutex.func (b *Bookings) Pickup(id string, today int) Result { b.mu.Lock() defer b.mu.Unlock() return b.service.Pickup(id, today)}
func (b *Bookings) GiveBack(id string, today int, branch string) Result { b.mu.Lock() defer b.mu.Unlock() return b.service.GiveBack(id, today, branch)}
func (b *Bookings) Available(c Category, branch string, dates DateRange) bool { b.mu.Lock() defer b.mu.Unlock() return b.service.Available(c, branch, dates)}// Payment, which takes a second or two and may decline.interface Payments { charge(customer: string, cents: number): Promise<boolean>;}
// Many customers book at once, from the website, the app and the counter. Checking that a car is// free and recording the reservation must be one step, or two customers can both see the last SUV// as free and both book it.//// JavaScript runs one callback at a time, so there is no lock. What makes it one step is that// reserve runs with no await inside it. Charging the card awaits, so it comes after: the// reservation is recorded first, which holds the car, and cancelled if the card is declined.class Bookings { constructor( private readonly service: RentalService, private readonly pricing: Pricing, private readonly payments: Payments, ) {}
async book(customer: string, category: Category, branch: string, range: DateRange): Promise<Reservation | null> { const r = this.service.reserve(customer, category, branch, range); if (!r) return null; if (await this.payments.charge(customer, this.pricing.quote(r).total)) return r; this.service.cancel(r.id); return null; }
pickup(id: string, today: number): Result { return this.service.pickup(id, today); }
giveBack(id: string, today: number, branch: string): Result { return this.service.giveBack(id, today, branch); }
available(category: Category, branch: string, range: DateRange): boolean { return this.service.available(category, branch, range); }}Why is this correct? Every booking’s check and record happen together under the lock, so every check sees every booking recorded before it. No day can ever have more bookings than cars.
What does it cost? One lock for all bookings, held only for a quick count. A declined card holds a car for a second or two, which is harmless.
Go deeperHow each language does it1 min
In Java, Python, C# and Go, requests run on their own threads, so the lock is needed. TypeScript
runs one callback at a time, so reserve, which never waits with await, is already one step. Only
the card charge waits, and it comes after the car is held.
How you’d prove it works
Section titled “How you’d prove it works”Every operation takes the day as an argument, so a test moves through a whole rental in a few lines, without waiting for real days to pass. The tests that matter:
- Bookings fill a branch until some day has no car left, and fit again once the cars are back.
- Two bookings on different days share one car, even though both overlap a third.
- Cancelling frees the days, and cancelling twice is refused.
- Pickup works only on the first day, gives a car of the right category, and only once.
- A one-way return moves the car to the other branch’s count.
- A late return leaves the next customer without a car until it comes back.
- A week’s quote is the daily rate with the long-rental discount.
- An invoice for a late, one-way return adds both fees.
- A declined card cancels the booking and frees the car.
- Eight customers booking two SUVs at once get exactly two bookings.
Go deeperThe tests in five languages2 min
The last test fails within a run in Java, Python and Go if the lock is removed: three or more bookings go through.
static final Map<Category, Long> RATES = Map.of(Category.ECONOMY, 3000L, Category.SUV, 6000L, Category.VAN, 8000L);
static Pricing pricing() { return new Pricing(List.of( new DailyRate(RATES), new LongRentalDiscount(7, 10, RATES), new OneWayFee(5000), new LateFee(150, RATES)));}
static RentalService twoSuvsDowntown() { RentalService s = new RentalService(); s.addCar("suv-1", Category.SUV, "downtown"); s.addCar("suv-2", Category.SUV, "downtown"); s.addCar("eco-1", Category.ECONOMY, "airport"); return s;}
@Testvoid reservesUntilEveryCarIsTakenOnSomeDay() { RentalService s = twoSuvsDowntown(); assertNotNull(s.reserve("ana", Category.SUV, "downtown", new DateRange(1, 4))); assertNotNull(s.reserve("ben", Category.SUV, "downtown", new DateRange(2, 5))); assertNull(s.reserve("cy", Category.SUV, "downtown", new DateRange(3, 4))); // both out on day 3 assertNotNull(s.reserve("cy", Category.SUV, "downtown", new DateRange(5, 6))); // both back by day 5 assertNull(s.reserve("di", Category.SUV, "airport", new DateRange(1, 2))); // no SUVs there}
@Testvoid reservationsOnDifferentDaysShareACar() { RentalService s = twoSuvsDowntown(); s.reserve("ana", Category.SUV, "downtown", new DateRange(1, 3)); s.reserve("ben", Category.SUV, "downtown", new DateRange(5, 7)); // Both overlap days 2 to 6, but never on the same day, so one car covers them. assertNotNull(s.reserve("cy", Category.SUV, "downtown", new DateRange(2, 6)));}
@Testvoid cancellingFreesTheDays() { RentalService s = twoSuvsDowntown(); Reservation a = s.reserve("ana", Category.SUV, "downtown", new DateRange(1, 3)); s.reserve("ben", Category.SUV, "downtown", new DateRange(1, 3)); assertFalse(s.available(Category.SUV, "downtown", new DateRange(2, 3))); assertEquals(Result.OK, s.cancel(a.id)); assertTrue(s.available(Category.SUV, "downtown", new DateRange(2, 3))); assertEquals(Result.WRONG_STATE, s.cancel(a.id));}
@Testvoid pickupAssignsACarOnTheFirstDayOnly() { RentalService s = twoSuvsDowntown(); Reservation r = s.reserve("ana", Category.SUV, "downtown", new DateRange(3, 5)); assertEquals(Result.NOT_TODAY, s.pickup(r.id, 2)); assertEquals(Result.OK, s.pickup(r.id, 3)); assertEquals(Status.ACTIVE, r.status); assertEquals(Category.SUV, r.car.category); assertEquals(Result.WRONG_STATE, s.pickup(r.id, 3)); assertEquals(Result.WRONG_STATE, s.cancel(r.id));}
@Testvoid aOneWayReturnMovesTheCar() { RentalService s = twoSuvsDowntown(); Reservation r = s.reserve("ana", Category.ECONOMY, "airport", new DateRange(1, 2)); s.pickup(r.id, 1); assertEquals(Result.OK, s.giveBack(r.id, 2, "downtown")); assertEquals("downtown", r.car.branch); assertTrue(s.available(Category.ECONOMY, "downtown", new DateRange(3, 4))); assertFalse(s.available(Category.ECONOMY, "airport", new DateRange(3, 4)));}
@Testvoid aLateReturnLeavesTheNextCustomerWithoutACar() { RentalService s = new RentalService(); s.addCar("suv-1", Category.SUV, "downtown"); Reservation first = s.reserve("ana", Category.SUV, "downtown", new DateRange(1, 3)); Reservation next = s.reserve("ben", Category.SUV, "downtown", new DateRange(3, 5)); s.pickup(first.id, 1); assertEquals(Result.NO_CAR, s.pickup(next.id, 3)); // ana hasn't brought it back s.giveBack(first.id, 3, "downtown"); assertEquals(Result.OK, s.pickup(next.id, 3));}
@Testvoid aQuoteAddsUpTheRules() { RentalService s = twoSuvsDowntown(); Reservation week = s.reserve("ana", Category.SUV, "downtown", new DateRange(1, 8)); Bill quote = pricing().quote(week); assertEquals(2, quote.lines().size()); assertEquals(7 * 6000 - 7 * 6000 / 10, quote.total()); // 7 days, 10% off}
@Testvoid theInvoiceChargesForWhatHappened() { RentalService s = twoSuvsDowntown(); Reservation r = s.reserve("ana", Category.ECONOMY, "airport", new DateRange(1, 3)); s.pickup(r.id, 1); s.giveBack(r.id, 4, "downtown"); // a day late, and one way Bill bill = pricing().invoice(r); assertEquals(2 * 3000 + 5000 + 3000 * 150 / 100, bill.total());}
@Testvoid aDeclinedCardReleasesTheCar() { RentalService s = twoSuvsDowntown(); Bookings declined = new Bookings(s, pricing(), (customer, cents) -> false); assertNull(declined.book("ana", Category.ECONOMY, "airport", new DateRange(1, 2))); assertTrue(s.available(Category.ECONOMY, "airport", new DateRange(1, 2)));}
@Testvoid racingBookingsNeverOverbook() throws Exception { for (int round = 0; round < 50; round++) { RentalService s = twoSuvsDowntown(); Bookings bookings = new Bookings(s, pricing(), (customer, cents) -> true); CountDownLatch start = new CountDownLatch(1); AtomicInteger booked = new AtomicInteger(); List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 8; i++) { String customer = "c" + i; Thread t = new Thread(() -> { try { start.await(); } catch (InterruptedException e) { return; } if (bookings.book(customer, Category.SUV, "downtown", new DateRange(1, 3)) != null) booked.incrementAndGet(); }); t.start(); threads.add(t); } start.countDown(); for (Thread t : threads) t.join(); assertEquals(2, booked.get()); // two SUVs, two bookings }}import sys
RATES = {Category.ECONOMY: 3000, Category.SUV: 6000, Category.VAN: 8000}
def pricing() -> Pricing: return Pricing([DailyRate(RATES), LongRentalDiscount(7, 10, RATES), OneWayFee(5000), LateFee(150, RATES)])
def two_suvs_downtown() -> RentalService: s = RentalService() s.add_car("suv-1", Category.SUV, "downtown") s.add_car("suv-2", Category.SUV, "downtown") s.add_car("eco-1", Category.ECONOMY, "airport") return s
class Approve: def charge(self, customer: str, cents: int) -> bool: return True
class Decline: def charge(self, customer: str, cents: int) -> bool: return False
def test_reserves_until_every_car_is_taken_on_some_day(): s = two_suvs_downtown() assert s.reserve("ana", Category.SUV, "downtown", DateRange(1, 4)) assert s.reserve("ben", Category.SUV, "downtown", DateRange(2, 5)) assert s.reserve("cy", Category.SUV, "downtown", DateRange(3, 4)) is None # both out on day 3 assert s.reserve("cy", Category.SUV, "downtown", DateRange(5, 6)) # both back by day 5 assert s.reserve("di", Category.SUV, "airport", DateRange(1, 2)) is None # no SUVs there
def test_reservations_on_different_days_share_a_car(): s = two_suvs_downtown() s.reserve("ana", Category.SUV, "downtown", DateRange(1, 3)) s.reserve("ben", Category.SUV, "downtown", DateRange(5, 7)) # Both overlap days 2 to 6, but never on the same day, so one car covers them. assert s.reserve("cy", Category.SUV, "downtown", DateRange(2, 6))
def test_cancelling_frees_the_days(): s = two_suvs_downtown() a = s.reserve("ana", Category.SUV, "downtown", DateRange(1, 3)) s.reserve("ben", Category.SUV, "downtown", DateRange(1, 3)) assert not s.available(Category.SUV, "downtown", DateRange(2, 3)) assert s.cancel(a.id) is Result.OK assert s.available(Category.SUV, "downtown", DateRange(2, 3)) assert s.cancel(a.id) is Result.WRONG_STATE
def test_pickup_assigns_a_car_on_the_first_day_only(): s = two_suvs_downtown() r = s.reserve("ana", Category.SUV, "downtown", DateRange(3, 5)) assert s.pickup(r.id, 2) is Result.NOT_TODAY assert s.pickup(r.id, 3) is Result.OK assert r.status is Status.ACTIVE assert r.car.category is Category.SUV assert s.pickup(r.id, 3) is Result.WRONG_STATE assert s.cancel(r.id) is Result.WRONG_STATE
def test_a_one_way_return_moves_the_car(): s = two_suvs_downtown() r = s.reserve("ana", Category.ECONOMY, "airport", DateRange(1, 2)) s.pickup(r.id, 1) assert s.give_back(r.id, 2, "downtown") is Result.OK assert r.car.branch == "downtown" assert s.available(Category.ECONOMY, "downtown", DateRange(3, 4)) assert not s.available(Category.ECONOMY, "airport", DateRange(3, 4))
def test_a_late_return_leaves_the_next_customer_without_a_car(): s = RentalService() s.add_car("suv-1", Category.SUV, "downtown") first = s.reserve("ana", Category.SUV, "downtown", DateRange(1, 3)) nxt = s.reserve("ben", Category.SUV, "downtown", DateRange(3, 5)) s.pickup(first.id, 1) assert s.pickup(nxt.id, 3) is Result.NO_CAR # ana hasn't brought it back s.give_back(first.id, 3, "downtown") assert s.pickup(nxt.id, 3) is Result.OK
def test_a_quote_adds_up_the_rules(): s = two_suvs_downtown() week = s.reserve("ana", Category.SUV, "downtown", DateRange(1, 8)) quote = pricing().quote(week) assert len(quote.lines) == 2 assert quote.total == 7 * 6000 - 7 * 6000 // 10 # 7 days, 10% off
def test_the_invoice_charges_for_what_happened(): s = two_suvs_downtown() r = s.reserve("ana", Category.ECONOMY, "airport", DateRange(1, 3)) s.pickup(r.id, 1) s.give_back(r.id, 4, "downtown") # a day late, and one way assert pricing().invoice(r).total == 2 * 3000 + 5000 + 3000 * 150 // 100
def test_a_declined_card_releases_the_car(): s = two_suvs_downtown() assert Bookings(s, pricing(), Decline()).book("ana", Category.ECONOMY, "airport", DateRange(1, 2)) is None assert s.available(Category.ECONOMY, "airport", DateRange(1, 2))
def test_racing_bookings_never_overbook(): old = sys.getswitchinterval() sys.setswitchinterval(1e-6) # switch threads often, so a missing lock shows up try: for _ in range(30): bookings = Bookings(two_suvs_downtown(), pricing(), Approve()) start = threading.Barrier(8)
def book(i: int): start.wait() return bookings.book(f"c{i}", Category.SUV, "downtown", DateRange(1, 3))
with ThreadPoolExecutor(8) as pool: results = list(pool.map(book, range(8))) assert sum(r is not None for r in results) == 2 # two SUVs, two bookings finally: sys.setswitchinterval(old)private static readonly Dictionary<Category, long> Rates = new(){ [Category.Economy] = 3000, [Category.Suv] = 6000, [Category.Van] = 8000,};
private static Pricing NewPricing() => new(new IPriceRule[]{ new DailyRate(Rates), new LongRentalDiscount(7, 10, Rates), new OneWayFee(5000), new LateFee(150, Rates),});
private static RentalService TwoSuvsDowntown(){ var s = new RentalService(); s.AddCar("suv-1", Category.Suv, "downtown"); s.AddCar("suv-2", Category.Suv, "downtown"); s.AddCar("eco-1", Category.Economy, "airport"); return s;}
private sealed class FixedPayments : IPayments{ private readonly bool _approve; public FixedPayments(bool approve) => _approve = approve; public bool Charge(string customer, long cents) => _approve;}
[Fact]public void ReservesUntilEveryCarIsTakenOnSomeDay(){ var s = TwoSuvsDowntown(); Assert.NotNull(s.Reserve("ana", Category.Suv, "downtown", new DateRange(1, 4))); Assert.NotNull(s.Reserve("ben", Category.Suv, "downtown", new DateRange(2, 5))); Assert.Null(s.Reserve("cy", Category.Suv, "downtown", new DateRange(3, 4))); // both out on day 3 Assert.NotNull(s.Reserve("cy", Category.Suv, "downtown", new DateRange(5, 6))); // both back by day 5 Assert.Null(s.Reserve("di", Category.Suv, "airport", new DateRange(1, 2))); // no SUVs there}
[Fact]public void ReservationsOnDifferentDaysShareACar(){ var s = TwoSuvsDowntown(); s.Reserve("ana", Category.Suv, "downtown", new DateRange(1, 3)); s.Reserve("ben", Category.Suv, "downtown", new DateRange(5, 7)); // Both overlap days 2 to 6, but never on the same day, so one car covers them. Assert.NotNull(s.Reserve("cy", Category.Suv, "downtown", new DateRange(2, 6)));}
[Fact]public void CancellingFreesTheDays(){ var s = TwoSuvsDowntown(); var a = s.Reserve("ana", Category.Suv, "downtown", new DateRange(1, 3))!; s.Reserve("ben", Category.Suv, "downtown", new DateRange(1, 3)); Assert.False(s.Available(Category.Suv, "downtown", new DateRange(2, 3))); Assert.Equal(Result.Ok, s.Cancel(a.Id)); Assert.True(s.Available(Category.Suv, "downtown", new DateRange(2, 3))); Assert.Equal(Result.WrongState, s.Cancel(a.Id));}
[Fact]public void PickupAssignsACarOnTheFirstDayOnly(){ var s = TwoSuvsDowntown(); var r = s.Reserve("ana", Category.Suv, "downtown", new DateRange(3, 5))!; Assert.Equal(Result.NotToday, s.Pickup(r.Id, 2)); Assert.Equal(Result.Ok, s.Pickup(r.Id, 3)); Assert.Equal(Status.Active, r.Status); Assert.Equal(Category.Suv, r.Car!.Category); Assert.Equal(Result.WrongState, s.Pickup(r.Id, 3)); Assert.Equal(Result.WrongState, s.Cancel(r.Id));}
[Fact]public void AOneWayReturnMovesTheCar(){ var s = TwoSuvsDowntown(); var r = s.Reserve("ana", Category.Economy, "airport", new DateRange(1, 2))!; s.Pickup(r.Id, 1); Assert.Equal(Result.Ok, s.GiveBack(r.Id, 2, "downtown")); Assert.Equal("downtown", r.Car!.Branch); Assert.True(s.Available(Category.Economy, "downtown", new DateRange(3, 4))); Assert.False(s.Available(Category.Economy, "airport", new DateRange(3, 4)));}
[Fact]public void ALateReturnLeavesTheNextCustomerWithoutACar(){ var s = new RentalService(); s.AddCar("suv-1", Category.Suv, "downtown"); var first = s.Reserve("ana", Category.Suv, "downtown", new DateRange(1, 3))!; var next = s.Reserve("ben", Category.Suv, "downtown", new DateRange(3, 5))!; s.Pickup(first.Id, 1); Assert.Equal(Result.NoCar, s.Pickup(next.Id, 3)); // ana hasn't brought it back s.GiveBack(first.Id, 3, "downtown"); Assert.Equal(Result.Ok, s.Pickup(next.Id, 3));}
[Fact]public void AQuoteAddsUpTheRules(){ var s = TwoSuvsDowntown(); var quote = NewPricing().Quote(s.Reserve("ana", Category.Suv, "downtown", new DateRange(1, 8))!); Assert.Equal(2, quote.Lines.Count); Assert.Equal(7 * 6000 - 7 * 6000 / 10, quote.Total); // 7 days, 10% off}
[Fact]public void TheInvoiceChargesForWhatHappened(){ var s = TwoSuvsDowntown(); var r = s.Reserve("ana", Category.Economy, "airport", new DateRange(1, 3))!; s.Pickup(r.Id, 1); s.GiveBack(r.Id, 4, "downtown"); // a day late, and one way Assert.Equal(2 * 3000 + 5000 + 3000 * 150 / 100, NewPricing().Invoice(r).Total);}
[Fact]public void ADeclinedCardReleasesTheCar(){ var s = TwoSuvsDowntown(); Assert.Null(new Bookings(s, NewPricing(), new FixedPayments(false)).Book("ana", Category.Economy, "airport", new DateRange(1, 2))); Assert.True(s.Available(Category.Economy, "airport", new DateRange(1, 2)));}
[Fact]public void RacingBookingsNeverOverbook(){ for (int round = 0; round < 30; round++) { var bookings = new Bookings(TwoSuvsDowntown(), NewPricing(), new FixedPayments(true)); using var start = new ManualResetEventSlim(); int booked = 0; var threads = Enumerable.Range(0, 8).Select(i => new Thread(() => { start.Wait(); if (bookings.Book($"c{i}", Category.Suv, "downtown", new DateRange(1, 3)) is not null) Interlocked.Increment(ref booked); })).ToList(); threads.ForEach(t => t.Start()); start.Set(); threads.ForEach(t => t.Join()); Assert.Equal(2, booked); // two SUVs, two bookings }}var rates = map[Category]int64{Economy: 3000, SUV: 6000, Van: 8000}
func pricing() Pricing { return Pricing{[]PriceRule{DailyRate{rates}, LongRentalDiscount{7, 10, rates}, OneWayFee{5000}, LateFee{150, rates}}}}
func twoSUVsDowntown() *RentalService { s := NewRentalService() s.AddCar("suv-1", SUV, "downtown") s.AddCar("suv-2", SUV, "downtown") s.AddCar("eco-1", Economy, "airport") return s}
type approve bool
func (a approve) Charge(string, int64) bool { return bool(a) }
func expect[T comparable](t *testing.T, want, got T) { t.Helper() if want != got { t.Fatalf("want %v, got %v", want, got) }}
func TestReservesUntilEveryCarIsTakenOnSomeDay(t *testing.T) { s := twoSUVsDowntown() expect(t, true, s.Reserve("ana", SUV, "downtown", DateRange{1, 4}) != nil) expect(t, true, s.Reserve("ben", SUV, "downtown", DateRange{2, 5}) != nil) expect(t, true, s.Reserve("cy", SUV, "downtown", DateRange{3, 4}) == nil) // both out on day 3 expect(t, true, s.Reserve("cy", SUV, "downtown", DateRange{5, 6}) != nil) // both back by day 5 expect(t, true, s.Reserve("di", SUV, "airport", DateRange{1, 2}) == nil) // no SUVs there}
func TestReservationsOnDifferentDaysShareACar(t *testing.T) { s := twoSUVsDowntown() s.Reserve("ana", SUV, "downtown", DateRange{1, 3}) s.Reserve("ben", SUV, "downtown", DateRange{5, 7}) // Both overlap days 2 to 6, but never on the same day, so one car covers them. expect(t, true, s.Reserve("cy", SUV, "downtown", DateRange{2, 6}) != nil)}
func TestCancellingFreesTheDays(t *testing.T) { s := twoSUVsDowntown() a := s.Reserve("ana", SUV, "downtown", DateRange{1, 3}) s.Reserve("ben", SUV, "downtown", DateRange{1, 3}) expect(t, false, s.Available(SUV, "downtown", DateRange{2, 3})) expect(t, OK, s.Cancel(a.ID)) expect(t, true, s.Available(SUV, "downtown", DateRange{2, 3})) expect(t, WrongState, s.Cancel(a.ID))}
func TestPickupAssignsACarOnTheFirstDayOnly(t *testing.T) { s := twoSUVsDowntown() r := s.Reserve("ana", SUV, "downtown", DateRange{3, 5}) expect(t, NotToday, s.Pickup(r.ID, 2)) expect(t, OK, s.Pickup(r.ID, 3)) expect(t, Active, r.Status) expect(t, SUV, r.Car.Category) expect(t, WrongState, s.Pickup(r.ID, 3)) expect(t, WrongState, s.Cancel(r.ID))}
func TestAOneWayReturnMovesTheCar(t *testing.T) { s := twoSUVsDowntown() r := s.Reserve("ana", Economy, "airport", DateRange{1, 2}) s.Pickup(r.ID, 1) expect(t, OK, s.GiveBack(r.ID, 2, "downtown")) expect(t, "downtown", r.Car.Branch) expect(t, true, s.Available(Economy, "downtown", DateRange{3, 4})) expect(t, false, s.Available(Economy, "airport", DateRange{3, 4}))}
func TestALateReturnLeavesTheNextCustomerWithoutACar(t *testing.T) { s := NewRentalService() s.AddCar("suv-1", SUV, "downtown") first := s.Reserve("ana", SUV, "downtown", DateRange{1, 3}) next := s.Reserve("ben", SUV, "downtown", DateRange{3, 5}) s.Pickup(first.ID, 1) expect(t, NoCar, s.Pickup(next.ID, 3)) // ana hasn't brought it back s.GiveBack(first.ID, 3, "downtown") expect(t, OK, s.Pickup(next.ID, 3))}
func TestAQuoteAddsUpTheRules(t *testing.T) { s := twoSUVsDowntown() week := s.Reserve("ana", SUV, "downtown", DateRange{1, 8}) quote := pricing().Quote(week) expect(t, 2, len(quote.Lines)) expect(t, int64(7*6000-7*6000/10), quote.Total()) // 7 days, 10% off}
func TestTheInvoiceChargesForWhatHappened(t *testing.T) { s := twoSUVsDowntown() r := s.Reserve("ana", Economy, "airport", DateRange{1, 3}) s.Pickup(r.ID, 1) s.GiveBack(r.ID, 4, "downtown") // a day late, and one way expect(t, int64(2*3000+5000+3000*150/100), pricing().Invoice(r).Total())}
func TestADeclinedCardReleasesTheCar(t *testing.T) { s := twoSUVsDowntown() expect(t, true, NewBookings(s, pricing(), approve(false)).Book("ana", Economy, "airport", DateRange{1, 2}) == nil) expect(t, true, s.Available(Economy, "airport", DateRange{1, 2}))}
func TestRacingBookingsNeverOverbook(t *testing.T) { for range 30 { b := NewBookings(twoSUVsDowntown(), pricing(), approve(true)) var booked atomic.Int32 var wg sync.WaitGroup for i := range 8 { wg.Add(1) go func() { defer wg.Done() if b.Book(fmt.Sprint("c", i), SUV, "downtown", DateRange{1, 3}) != nil { booked.Add(1) } }() } wg.Wait() expect(t, 2, int(booked.Load())) // two SUVs, two bookings }}import { test } from 'node:test';import assert from 'node:assert/strict';
const RATES: Rates = { economy: 3000, suv: 6000, van: 8000 };
const pricing = () => new Pricing([new DailyRate(RATES), new LongRentalDiscount(7, 10, RATES), new OneWayFee(5000), new LateFee(150, RATES)]);
function twoSuvsDowntown(): RentalService { const s = new RentalService(); s.addCar('suv-1', 'suv', 'downtown'); s.addCar('suv-2', 'suv', 'downtown'); s.addCar('eco-1', 'economy', 'airport'); return s;}
// A card network that answers after a short wait.const payments = (approve: boolean): Payments => ({ charge: () => new Promise((resolve) => setTimeout(() => resolve(approve), 5)),});
test('reserves until every car is taken on some day', () => { const s = twoSuvsDowntown(); assert.ok(s.reserve('ana', 'suv', 'downtown', new DateRange(1, 4))); assert.ok(s.reserve('ben', 'suv', 'downtown', new DateRange(2, 5))); assert.equal(s.reserve('cy', 'suv', 'downtown', new DateRange(3, 4)), null); // both out on day 3 assert.ok(s.reserve('cy', 'suv', 'downtown', new DateRange(5, 6))); // both back by day 5 assert.equal(s.reserve('di', 'suv', 'airport', new DateRange(1, 2)), null); // no SUVs there});
test('reservations on different days share a car', () => { const s = twoSuvsDowntown(); s.reserve('ana', 'suv', 'downtown', new DateRange(1, 3)); s.reserve('ben', 'suv', 'downtown', new DateRange(5, 7)); // Both overlap days 2 to 6, but never on the same day, so one car covers them. assert.ok(s.reserve('cy', 'suv', 'downtown', new DateRange(2, 6)));});
test('cancelling frees the days', () => { const s = twoSuvsDowntown(); const a = s.reserve('ana', 'suv', 'downtown', new DateRange(1, 3))!; s.reserve('ben', 'suv', 'downtown', new DateRange(1, 3)); assert.equal(s.available('suv', 'downtown', new DateRange(2, 3)), false); assert.equal(s.cancel(a.id), 'ok'); assert.equal(s.available('suv', 'downtown', new DateRange(2, 3)), true); assert.equal(s.cancel(a.id), 'wrong-state');});
test('pickup assigns a car on the first day only', () => { const s = twoSuvsDowntown(); const r = s.reserve('ana', 'suv', 'downtown', new DateRange(3, 5))!; assert.equal(s.pickup(r.id, 2), 'not-today'); assert.equal(s.pickup(r.id, 3), 'ok'); assert.equal(r.status, 'active'); assert.equal(r.car!.category, 'suv'); assert.equal(s.pickup(r.id, 3), 'wrong-state'); assert.equal(s.cancel(r.id), 'wrong-state');});
test('a one-way return moves the car', () => { const s = twoSuvsDowntown(); const r = s.reserve('ana', 'economy', 'airport', new DateRange(1, 2))!; s.pickup(r.id, 1); assert.equal(s.giveBack(r.id, 2, 'downtown'), 'ok'); assert.equal(r.car!.branch, 'downtown'); assert.equal(s.available('economy', 'downtown', new DateRange(3, 4)), true); assert.equal(s.available('economy', 'airport', new DateRange(3, 4)), false);});
test('a late return leaves the next customer without a car', () => { const s = new RentalService(); s.addCar('suv-1', 'suv', 'downtown'); const first = s.reserve('ana', 'suv', 'downtown', new DateRange(1, 3))!; const next = s.reserve('ben', 'suv', 'downtown', new DateRange(3, 5))!; s.pickup(first.id, 1); assert.equal(s.pickup(next.id, 3), 'no-car'); // ana hasn't brought it back s.giveBack(first.id, 3, 'downtown'); assert.equal(s.pickup(next.id, 3), 'ok');});
test('a quote adds up the rules', () => { const s = twoSuvsDowntown(); const quote = pricing().quote(s.reserve('ana', 'suv', 'downtown', new DateRange(1, 8))!); assert.equal(quote.lines.length, 2); assert.equal(quote.total, 7 * 6000 - (7 * 6000) / 10); // 7 days, 10% off});
test('the invoice charges for what happened', () => { const s = twoSuvsDowntown(); const r = s.reserve('ana', 'economy', 'airport', new DateRange(1, 3))!; s.pickup(r.id, 1); s.giveBack(r.id, 4, 'downtown'); // a day late, and one way assert.equal(pricing().invoice(r).total, 2 * 3000 + 5000 + (3000 * 150) / 100);});
test('a declined card releases the car', async () => { const s = twoSuvsDowntown(); assert.equal(await new Bookings(s, pricing(), payments(false)).book('ana', 'economy', 'airport', new DateRange(1, 2)), null); assert.equal(s.available('economy', 'airport', new DateRange(1, 2)), true);});
test('racing bookings never overbook', async () => { const bookings = new Bookings(twoSuvsDowntown(), pricing(), payments(true)); const results = await Promise.all( Array.from({ length: 8 }, (_, i) => bookings.book(`c${i}`, 'suv', 'downtown', new DateRange(1, 3))), ); assert.equal(results.filter((r) => r !== null).length, 2); // two SUVs, two bookings});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| No car of that category at that branch | Not available | available |
| Two bookings back to back on the same car | Both fit: the end day is exclusive | DateRange.contains |
| A range with no days | Refused when the range is made | DateRange |
| Cancel after pickup | WRONG_STATE |
cancel |
| Pickup on the wrong day | NOT_TODAY |
pickup |
| Pickup twice | WRONG_STATE |
pickup |
| The last car came back late | NO_CAR at pickup; staff upgrade or move a car |
pickup |
| Returned to another branch | The car joins that branch; one-way fee | giveBack, OneWayFee |
| Returned late | Charged per late day at a higher rate | LateFee |
| Returned early | Charged for the booked days | DailyRate |
| Card declined | Booking cancelled, car free again | Bookings.book |
| Two customers, one car | One booking, one “none available” | Bookings lock |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| What is booked | A category at a branch for a range of days | Choosing a specific car |
| Availability | Checked day by day against the fleet | A single overlap count, which is wrong |
| Choosing the car | At pickup, from the lot | Knowing the car ahead of time |
| Late and one-way | Allowed, noticed at pickup as NO_CAR |
A guarantee every booking has a car waiting |
| Pricing | A list of rules producing lines; quote and invoice share them | A single price stored at booking |
| Booking at once | Check and record under a lock; pay outside it | A car held for a second on a declined card |
Go deeperGenuinely different designs, and when to pick them2 min
Book a specific car. Car-sharing services like Zipcar book a particular car at a particular spot, so availability is a simple overlap check per car, and the car is known from the start. It fits when customers choose the car on a map, and loses the flexibility of a pool.
Overbooking on purpose. Some rental companies accept a few percent more bookings than cars,
because some customers never show up. Availability becomes booked < fleet × 1.05, and the counter
needs a plan for the day everyone does show up.
Event sourcing. Store every booking, pickup and return as an event, and work out availability from them. It gives a full history of what happened, which rental disputes often need, at the cost of building the side that answers questions.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. Book ahead or walk in, category or car, branches, one-way returns, pricing.
- 3 to 5. The promises. Name “never promise more cars than a branch has, on any day” as the hard one.
- 5 to 22. Renting on the spot, then bookings by category checked day by day, then pickup and
return with states, then pricing rules. Code
available,pickupand one price rule. - 22 to 24. Walk through the full diagram: promises are counts, cars are chosen late.
- 24 to 29. Two customers and one car: the lock, and paying outside it.
- 29 to 45. Many servers: a conditional update per day. Then your own questions.
If time is short, give pricing one sentence (“a list of rules that each add lines to a bill”), and spend the time on day-by-day availability and the booking race. Those decide this question for most interviewers.
Follow-up questions
Section titled “Follow-up questions”We now have 2,000 branches, and the bookings live in a database across many servers. How does availability work there?
Whether you can move the one-step check into the database instead of a lock in memory. A lock in one server’s memory protects nothing once there are two servers.
Most extensions fit into one place:
| Extension | What changes | Touches booking? |
|---|---|---|
| Hourly rentals | Slots instead of days in the per-day check | Yes, the unit |
| Upgrades at pickup | Pickup falls back to a higher category with a car on the lot | No |
| Cars out of service | Removed from the fleet count for those days | Yes, the count |
| Moving cars around | A job that moves cars between branches before they’re needed | No |
| Coupons, weekend rates | More price rules | No |
| Many servers | A conditional update per day in the database, or holds that expire | Yes |
Go deeperMoving to a database and many servers, step by step2 min
With several servers, the lock in memory is gone, so the one step must happen in the database.
- Keep an inventory row per branch, category and day. Each row holds the fleet size and how many are booked. A week’s booking touches seven rows.
- Book in one transaction, with a condition on every row. For each day:
UPDATE inventory SET booked = booked + 1 WHERE branch = ? AND category = ? AND day = ? AND booked < fleet. If any update changes no row, that day is full, and the whole transaction is rolled back. It’s the same day-by-day check, made into one step by the database. - Hold the car, then pay. The booking is written with a status of “held” and an expiry, ten minutes ahead. Payment runs with no transaction open. Success turns the hold into a booking.
- Let holds expire. If the server crashes during payment, nobody cancels the booking. A job
finds expired holds, and gives their days back by lowering
booked. In memory, our code cancelled straight away; with many servers, an expiry is what makes that safe. - Two busy branches don’t block each other. Each transaction locks only its own rows, so bookings at the airport never wait for bookings in the city.
The new costs are a row per branch, category and day (2,000 branches, 5 categories and a year ahead is about 3.6 million small rows), and a job to clean up holds.
Go deeperFour more follow-up questions2 min
Interviewer asksHow do you show a customer which categories are free for their dates?
Call available for each category at the branch. It’s a handful of categories, each a day-by-day
count, so it’s fast. With the per-day inventory rows, it’s one query: the categories whose booked
count stays under the fleet on every day of the range.
Interviewer asksWhat if the customer wants to extend their rental while they have the car?
Treat it as a booking for the extra days, same category and branch, checked the same way. If it fits, extend the reservation’s range. If it doesn’t, someone else is waiting for a car, and the customer is told to return on time or pay the late fee.
Interviewer asksHow would you handle a car that breaks down during a rental?
Mark the car out of service, and swap it: the reservation stays active and points to a new car from the nearest branch. Because the reservation is for a category, nothing else about it changes. The broken car leaves the fleet count until it’s repaired.
Interviewer asksWhy not let the database pick the car at booking with a unique index?
You can, with a row per car per day, and it makes double-booking a car impossible. But you have to choose a car at booking, which brings back the problems with late and one-way returns. A count per category keeps the same promise, never more bookings than cars, without tying a booking to a car early.
Further reading
Section titled “Further reading”- Strategy pattern, the shape of the pricing rules
- Design a hotel booking system, the same promise-a-type, assign-at-arrival idea for rooms
- Design a parking lot, the walk-up version with no dates
- Thread safety basics, check-then-act races and locks
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.