SevyDevy

Guide · System design

LLD: Design a Car Rental System

25 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • A car rental LLD (low-level design) names the classes, fields and methods inside one rental service. It covers hubs (pickup points), cars, car types, customers, reservations and the rule against promising one car twice.
  • The core is a date-range check. Two trips clash when each one starts before the other ends, after you add a turnaround gap (cleaning and checking time) to each trip.
  • Customers book a car type, such as a 7-seat SUV, and the system holds one real car. Cars and bookings move through states (labels like RENTED) that change only through checked methods.
  • Checking that a car is free and holding it must be one step. Java's synchronized keyword does this inside one program. Across servers, a PostgreSQL (open-source database) exclusion constraint makes the database refuse overlapping trips.
  • Beginners can stop after the section on states. Engineers then get a tested PostgreSQL constraint, why counting bookings turns customers away while counting the busiest moment does not, one-way trips and late returns.

What goes wrong when a car rental system books cars naively?

A car rental system goes wrong when it checks that a car is free in one step and books it in another. Two customers both see the same car as free, and both get a confirmation. A good car rental LLD makes those two steps one.

Picture a common setup. A self-drive car rental service runs two pickup hubs in one Indian city, one at the airport and one in the city centre. Its fleet has 60 cars: hatchbacks, sedans and 7-seat SUVs. The setup is made up, but the problems are real.

The first booking app kept a list of bookings and a status column on each car. Then came a long festival weekend, and three things went wrong in four days.

  • Two families booked the last 7-seat SUV at the airport hub for the same Friday. Both got a confirmation SMS, and one family waited two hours at arrivals.
  • A hatchback came back at 9:40 with a scraped bumper. It went out again at 10:00, and the next customer was nearly charged for the scrape.
  • Many customers took sedans one way, from the city to the airport. By Sunday the city hub had none left, while nine sat idle at the airport.

The team wants a design that makes these mistakes impossible, not just less likely. This guide builds it class by class: the date check, the booking code and the states, then the database guard and one-way trips.

What is a car rental LLD?

A car rental LLD is a low-level design for renting cars from fixed hubs: the classes, their fields, how they link and which class owns which rule. Low-level design is the level where you name classes and methods.

A high-level design draws servers, databases and queues. A low-level design zooms into one service and asks which objects exist. Car rental makes a good interview problem because one small problem tests inventory, date ranges, states and concurrency (many requests at once).

Start the way you should start in an interview: write down what the system must do. These are the functional requirements, the features a user can see.

  • A customer searches by hub, car type, pickup time and return time, and sees what is free.
  • A customer reserves a car type for a time range and gets a reservation number.
  • Staff hand over a car at pickup and record the fuel level and odometer reading (the total distance meter).
  • Staff check the car at return, record damage, and close the bill with any late or damage charges.
  • A customer can cancel before pickup, and the car becomes free again for those dates.
  • A customer may return the car to a different hub, called a one-way trip.

Also write what you are leaving out: payments, loyalty points, driving licence checks and the app screens. Saying this out loud keeps an interview on the classes that matter.

Which classes does a car rental system need?

A car rental system needs about seven core classes: Hub, CarType, Car, Customer, Reservation, ReservationBook and Invoice. Each class owns one kind of fact, and only ReservationBook decides whether a car is free.

The split between CarType and Car matters most. CarType is what the customer asks for, like "SUV, 7 seats, automatic". Car is the actual vehicle, with a plate number, an odometer and its own dents.

Think of a costume shop that rents crowns to Ramlila troupes, the groups that stage the Ramayana as a folk play around Dussehra. Troupes ask for "a Ravana crown", not the third crown on the shelf. The owner picks which one to hand over.

  • "A Ravana crown" is a CarType: the kind of thing the customer wants.
  • The particular crown on the shelf is a Car: one real object with its own scratches.
  • The owner's register of who has which crown on which nights is ReservationBook.
  • The table where every returned crown is checked before it goes out again is the turnaround gap, covered below.

Where the picture breaks: crowns stay in one shop, but cars can end a trip at the other hub. And one owner at one register cannot promise a crown twice, while two servers can, as you will see.

Core classes in the two-hub design, with made-up rates in rupees (Rs)
ClassWhat it holdsExample
HubName, address, the cars parked there nowAirport hub, 38 cars
CarTypeCategory, seats, gearbox, daily rateSUV, 7 seats, Rs 4,200 a day
CarPlate number, type, current hub, state, odometerOne SUV, state AVAILABLE
CustomerName, phone, licence numberA family flying in for the festival
ReservationCustomer, car, pickup and return hubs, booked trip times, stateFriday 10:00 to Monday 10:00
ReservationBookEvery car's busy ranges; the only place that says yes or noOne object for the whole fleet
InvoiceBase rent, late fee, fuel and damage charges, totalRs 12,600 for 3 days plus Rs 2,000 for a scrape

Look at ReservationBook in the diagram below. Every booking request passes through it, and it is the only box that writes to the calendar of busy times. The other boxes hold facts, but they never decide availability.

Core classes of a car rental LLD

Notice that only ReservationBook creates reservations, so the one-car-one-trip rule lives in a single place.

Diagram connections:

  • Customer to RentalService (Search or reserve)
  • RentalService to ReservationBook (Ask for a car)
  • ReservationBook to Hub (Cars of this type)
  • Hub to Car (Holds)
  • ReservationBook to Reservation (Creates)
  • RentalService to Invoice (Bills at return)
  • Customer: Searches, reserves and cancels
  • RentalService: Entry point for search, pickup and return
  • ReservationBook: Only class that says a car is free
  • Hub: Airport or city; lists cars parked there
  • Car: Plate, type, state, current hub
  • Reservation: Customer, car, hubs, booked trip, state
  • Invoice: Rent, late fee and damage charge

Notice two choices. Car keeps a reference to its current hub, because cars move between hubs. Reservation points to a real Car, not just a CarType, so a hold always means one specific vehicle.

How do you check if a rental car is free for given dates?

A car is free for a trip when none of its existing trips overlap the new one. Two time ranges overlap when each one starts before the other one ends.

That one rule covers every case. A new trip may start inside an old one, end inside it, sit wholly inside it or wrap around it. In each case both starts come before both ends, so the rule catches it.

Store each trip as a half-open range, written [start, end). The start time is included and the end time is not. So a trip ending Monday at 10:00 does not clash with one starting Monday at 10:00.

PostgreSQL uses the same default. Its two-argument range constructors build a range with an included lower bound and an excluded upper bound. Back-to-back ranges then fit together with no gap and no overlap.

Here is the DateRange class. It is a Java record, a short way to write a class whose fields are fixed once it is built (standard since Java 16). It uses LocalDateTime, a date and time with no time zone.

java
import java.time.Duration;
import java.time.LocalDateTime;

public record DateRange(LocalDateTime start, LocalDateTime end) {
    public DateRange {
        if (!start.isBefore(end)) {
            throw new IllegalArgumentException("end must be after start");
        }
    }

    // Half-open [start, end): a trip ending at 10:00 does not clash with one starting at 10:00
    public boolean overlaps(DateRange other) {
        return start.isBefore(other.end) && other.start.isBefore(end);
    }

    public DateRange withTurnaround(Duration gap) {
        return new DateRange(start, end.plus(gap));
    }
}

This is Martin Fowler's Range pattern: keep a start and an end together in one object that can answer overlap questions. Three parts matter. The constructor refuses a range whose end is not after its start, so a broken range can never exist. The overlaps method is the whole availability rule in one line. LocalDateTime is immutable (it never changes once built), so withTurnaround returns a new range instead of stretching the old one.

LocalDateTime is safe here because both hubs share one local clock, and India Standard Time has no daylight-saving clock changes. If you ever serve hubs in different time zones, store an Instant (one exact moment) instead.

You can check the rule yourself. Save this next to DateRange.java, run javac DateRange.java OverlapDemo.java, then run java OverlapDemo.

java
import java.time.Duration;
import java.time.LocalDateTime;

public class OverlapDemo {
    public static void main(String[] args) {
        LocalDateTime friday = LocalDateTime.of(2026, 10, 23, 10, 0);
        LocalDateTime monday = LocalDateTime.of(2026, 10, 26, 10, 0);
        DateRange trip = new DateRange(friday, monday);
        DateRange next = new DateRange(monday, monday.plusDays(1));
        DateRange padded = trip.withTurnaround(Duration.ofHours(3));

        System.out.println(trip.overlaps(next));    // back to back
        System.out.println(padded.overlaps(next));  // with the 3-hour gap
        System.out.println(padded.end());
    }
}

On Java 21 it prints three lines: false, true and 2026-10-26T13:00. The back-to-back trips do not clash. Once the 3-hour gap is added, the padded trip runs until 13:00 on Monday and does clash.

Why every trip needs a turnaround gap

A turnaround gap is a block of time after each trip when the car cannot be rented. Staff use it to clean the car, refuel it and check it for damage.

The scraped hatchback had no such gap. It came back at 9:40 and left at 10:00, so nobody could tell which customer made the scrape. The team now blocks 3 hours (made up) after every trip. A trip from Friday 10:00 to Monday 10:00 blocks the car until Monday 13:00.

How do you reserve a car without double booking?

You avoid double booking by making the check and the hold one indivisible step. Nothing else may slip in between finding a free car and marking it busy.

The festival bug was a race condition: a bug where the result depends on which of two requests runs first. Both families' requests read the calendar, both saw the SUV free, and both wrote a booking.

First, the small supporting classes. They are plain data holders, and a Java enum is a fixed list of named values, so a typo like RETUNED fails to compile. In a real project each class gets its own file. Skim this block; the ReservationBook after it is the part to read.

java
import java.util.List;

enum CarState { AVAILABLE, RENTED, IN_INSPECTION, IN_MAINTENANCE }

record CarType(String category, int seats, int dailyRateRupees) {}

record Customer(String name, String phone) {}

class Car {
    final String plate;
    final CarType type;
    Hub hub;                              // changes after a one-way trip
    CarState state = CarState.AVAILABLE;

    Car(String plate, CarType type, Hub hub) {
        this.plate = plate;
        this.type = type;
        this.hub = hub;
    }
    // No equals override: one Car object is one vehicle, so it is a safe map key
}

record Hub(String name, List<Car> cars) {
    List<Car> carsOfType(CarType type) {
        return cars.stream().filter(c -> c.type.equals(type)).toList();
    }
}

class NoCarAvailableException extends RuntimeException {
    NoCarAvailableException(CarType type, Hub hub, DateRange trip) {
        super("No " + type.category() + " free at " + hub.name() + " for " + trip);
    }
}

Now the ReservationBook. It keeps a list of busy ranges for each car, and Reservation is shown in the states section below.

java
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class ReservationBook {
    private static final Duration TURNAROUND = Duration.ofHours(3);
    private final Map<Car, List<DateRange>> calendar = new HashMap<>();

    public synchronized Reservation reserve(Customer who, CarType type,
                                            Hub hub, DateRange trip) {
        DateRange blocked = trip.withTurnaround(TURNAROUND);
        for (Car car : hub.carsOfType(type)) {
            List<DateRange> busy = calendar.computeIfAbsent(car, c -> new ArrayList<>());
            if (busy.stream().noneMatch(b -> b.overlaps(blocked))) {
                busy.add(blocked);
                return new Reservation(who, car, hub, trip);
            }
        }
        throw new NoCarAvailableException(type, hub, trip);
    }

    public synchronized void cancel(Reservation r) {
        r.cancel();
        calendar.get(r.car()).remove(r.trip().withTurnaround(TURNAROUND));
    }
}

The word synchronized fixes the race. While one thread (one request being handled) runs a synchronized method on this ReservationBook, other threads calling its synchronized methods wait. So the check and busy.add happen together.

The loop gives the customer the first free car of the asked type. If none is free, the method throws instead of returning null, so callers cannot forget the failure. Cancel takes the same lock and removes the padded range it added; records compare by value, so remove finds the equal range.

One lock for the whole fleet is the simplest correct choice. It also makes every booking in the fleet wait in one line, which the database guard later in this guide does not need.

Pause and predict: what happens if you delete the word synchronized and let eight requests ask for the last SUV at the same instant?

We ran it on Java 21 on a 2-core test machine: 2,000 rounds of eight requests per run, over many runs. Without synchronized, most runs had no double booking at all, and the worst had 5 bad rounds in 2,000.

Nothing slow sits between the check and the write, so the race window is tiny, far under a millisecond. That is exactly why races slip past testing and then appear on a busy festival weekend.

Then we added a 1 ms pause between the check and the write, like the database call the first app made. Now nearly every round (1,995 to 2,000 of 2,000 per run) gave the one SUV to several requests, often all eight. The unlocked version also threw unrelated errors in some rounds.

With synchronized, every round in every run gave exactly one confirmation, pause or not.

Follow one reservation request through the diagram below. Each box is one step, and the detail line says which object talks to which.

One reservation request, step by step

The check and the hold sit inside one locked step, so two requests cannot both win the last SUV.

Diagram connections:

  • Customer asks for an SUV to Take the book's lock
  • Take the book's lock to Add the turnaround gap
  • Add the turnaround gap to Any SUV free here?
  • Any SUV free here? to Add range, create booking (Yes)
  • Any SUV free here? to Say no car is free (No)
  • Add range, create booking to Send confirmation
  • Customer asks for an SUV: App → RentalService
  • Add the turnaround gap: ReservationBook → DateRange
  • Take the book's lock: RentalService → ReservationBook
  • Any SUV free here?: ReservationBook → Hub
  • Add range, create booking: ReservationBook → Reservation
  • Say no car is free: ReservationBook → RentalService
  • Send confirmation: RentalService → Customer

What states does a rental car go through?

A rental car moves between four states: available, rented, in inspection and in maintenance. A reservation has its own states: confirmed, picked up, returned and closed, or cancelled and no-show.

A state is a label that says what an object may do next. Each change goes through one method that checks the move is allowed, so no screen or script can jump a step.

  • Car AVAILABLE: parked at a hub, clean and checked. It can be handed out.
  • Car RENTED: with a customer. Staff cannot hand it to anyone else.
  • Car IN_INSPECTION: just returned. Staff are checking fuel, odometer and damage.
  • Car IN_MAINTENANCE: in the workshop. Its future reservations must move to another car of the same type.

Here is Reservation with its states. Every public method names the one state it may start from. RentalService changes the car's own state next to these calls, for example to RENTED at pickup.

java
public class Reservation {
    public enum Status { CONFIRMED, PICKED_UP, RETURNED, CLOSED, CANCELLED, NO_SHOW }

    private final Customer who;
    private final Car car;
    private final Hub pickupHub;
    private final DateRange trip;      // the booked trip, never the padded range
    private Status status = Status.CONFIRMED;

    public Reservation(Customer who, Car car, Hub pickupHub, DateRange trip) {
        this.who = who;
        this.car = car;
        this.pickupHub = pickupHub;
        this.trip = trip;
    }

    public void pickUp()           { move(Status.CONFIRMED, Status.PICKED_UP); }
    public void dropOff()          { move(Status.PICKED_UP, Status.RETURNED); }
    public void finishInspection() { move(Status.RETURNED, Status.CLOSED); }
    public void cancel()           { move(Status.CONFIRMED, Status.CANCELLED); }
    public void markNoShow()       { move(Status.CONFIRMED, Status.NO_SHOW); }

    private synchronized void move(Status from, Status to) {
        if (status != from) {
            throw new IllegalStateException(status + " cannot become " + to);
        }
        status = to;
    }

    public Car car()                    { return car; }
    public DateRange trip()             { return trip; }
    public synchronized Status status() { return status; }
}

Calling pickUp on a cancelled booking throws an IllegalStateException with the message CANCELLED cannot become PICKED_UP. That is the point: a wrong move fails loudly instead of quietly corrupting data.

Read the diagram below from the top. The only way to reach Closed is through Returned, the inspection step, so nobody can skip the damage check.

A reservation's life

Every arrow is one method on Reservation; any other move throws an error.

Diagram connections:

  • Confirmed to Cancelled (cancel())
  • Confirmed to No-show (markNoShow())
  • Confirmed to Picked up (pickUp())
  • Picked up to Returned (dropOff())
  • Returned to Closed (finishInspection())
  • Confirmed: Car held, waiting for pickup day
  • Cancelled: Customer cancels before pickup
  • No-show: Nobody came within 4 hours of pickup (made up)
  • Picked up: Car is RENTED; fuel and km noted
  • Returned: Car is IN_INSPECTION at a hub
  • Closed: Invoice paid; car released after checks

Cancelled and No-show both free the car's blocked range in ReservationBook; a markNoShow method on the book works like cancel. Closed does not need to, because that trip is over and its range sits in the past.

Here is what happens at the return counter when a hatchback comes back.

  1. Mark the drop-off

    Staff scan the plate. The reservation moves to Returned, and the car moves to IN_INSPECTION at the hub where it arrived.

  2. Read the meters

    Staff record fuel level and odometer. The service compares them with the readings saved at pickup.

  3. Walk around the car

    Staff photograph each side and log any new dent or scrape against this reservation.

  4. Build the invoice

    Invoice adds base rent, any late fee, a fuel top-up and any damage charge from a fixed price list.

  5. Release the car

    finishInspection closes the reservation. The car becomes AVAILABLE, or IN_MAINTENANCE if the damage needs the workshop.

Beginners can stop here, or jump to the interview section near the end. You now have the classes, the overlap rule, a locked reserve step and the states. The rest is for engineers who must run this across servers and hubs.

How do you stop double booking across many servers?

You stop it by letting the database refuse the second booking. A synchronized method only protects one running Java program, and two copies behind a load balancer (a box that spreads requests across servers) each have their own lock.

PostgreSQL has a tool for exactly this, the exclusion constraint. It says no two rows may match on every listed comparison. Here the comparisons are "same car" and "overlapping blocked range".

sql
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE reservation (
    id                 bigserial PRIMARY KEY,
    car_id             bigint    NOT NULL,
    pickup_at          timestamp NOT NULL,
    return_at          timestamp NOT NULL,   -- booked return, kept for billing
    expected_return_at timestamp,            -- set only when a car runs late
    status             text      NOT NULL DEFAULT 'CONFIRMED'
        CHECK (status IN ('CONFIRMED', 'PICKED_UP', 'RETURNED',
                          'CLOSED', 'CANCELLED', 'NO_SHOW')),
    blocked            tsrange   GENERATED ALWAYS AS
        (tsrange(pickup_at,
                 greatest(return_at, expected_return_at) + interval '3 hours')) STORED,
    EXCLUDE USING gist (car_id WITH =, blocked WITH &&)
        WHERE (status NOT IN ('CANCELLED', 'NO_SHOW'))
        DEFERRABLE
);

Each part has a job. tsrange is PostgreSQL's range of timestamps. The blocked column is computed from the booked times plus the 3-hour gap, so the table stores the booked trip and guards the padded one. The && operator means "overlaps" for ranges.

Mixing a plain column such as car_id into a GiST constraint needs the btree_gist extension, an add-on module you switch on per database. GiST is a PostgreSQL index type that can index ranges. The WHERE clause limits the rule to live bookings, so cancelled and no-show rows stop blocking new ones. The CHECK list refuses any other status, so a typo like CANCELED cannot block a car forever. expected_return_at stays empty until a car runs late, and DEFERRABLE matters when cars are reassigned; both come later.

Check it yourself with three inserts for the same car.

sql
INSERT INTO reservation (car_id, pickup_at, return_at)
VALUES (7, '2026-10-23 10:00', '2026-10-26 10:00');   -- Friday to Monday: accepted

INSERT INTO reservation (car_id, pickup_at, return_at)
VALUES (7, '2026-10-26 12:00', '2026-10-27 10:00');   -- inside the gap: refused

INSERT INTO reservation (car_id, pickup_at, return_at)
VALUES (7, '2026-10-26 13:00', '2026-10-27 10:00');   -- right after the gap: accepted

On PostgreSQL 16.15 the first and third inserts print INSERT 0 1. The second fails with conflicting key value violates exclusion constraint "reservation_car_id_blocked_excl", error code 23P01. The same syntax appears in the PostgreSQL 18 manual, current as of 19 October 2026.

The constraint guards one car; it does not choose one. So the service picks a candidate car, tries the insert, and on error 23P01 tries the next car of that type. Run each try in its own transaction, a group of changes that succeed or fail together. PostgreSQL rejects every later statement in a transaction that has already failed, error 25P02, unless you roll back to a savepoint (a marker set before the try).

A second option is optimistic locking: each car's calendar row carries a version number. A save succeeds only if the version is unchanged since you read it. Otherwise you read again and retry. In Java, Jakarta Persistence, the enterprise Java standard for saving Java objects in a database, does this with its Version annotation and an OptimisticLockException.

How many cars of one type can you promise for a time range?

You can promise a car whenever, at the busiest moment of the new trip, fewer existing trips are active than you have working cars. That holds for round trips at one hub, with every trip already padded with its gap.

A tempting shortcut is to count the bookings that overlap the new trip and compare with the fleet size. It never over-promises, but it turns away customers when a car could be found.

Here is that trap with two SUVs. One booking needs Monday to Tuesday, another Thursday to Friday. A new request for Monday to Friday overlaps both, so the count says two busy out of two. Yet if both short trips go to one SUV, the other is free all week.

The per-car check in the reserve method has a quieter version of the same problem. It keeps every earlier choice of car, so earlier choices can block a booking that a reshuffle would fit.

For example, two SUVs take bookings in this order, each range half-open as before. Days 3 to 4 go to SUV A, days 1 to 5 to SUV B, and days 8 to 11 back to SUV A. A request for days 4 to 9 then fails on both cars.

Yet no moment has more than two trips active. Move days 8 to 11 onto SUV B, and SUV A is free for days 4 to 9.

The fix comes from a classic result called interval partitioning. Sort trips by start time and give each one any car that is free. This uses exactly as many cars as the busiest moment needs, and no schedule can use fewer.

Three ways to answer "can we take this booking?", from our run of 20,000 random small cases for one hub (14,712 of them could fit)
RuleWhat it checksRefused a booking that could fitPromised a car that was not there
Count overlapping bookingsBookings that touch the new trip, compared with fleet size1,595 timesNever
Per-car check, first free carEach car's own calendar, keeping earlier choices245 timesNever
Busiest moment, then re-planMost trips active at once, then reassign cars by start timeNeverNever

So a stronger design uses the per-car check first, because it is cheap. If that fails but the busiest-moment count still fits, it reassigns cars for trips not yet picked up. Trips already on the road keep their car.

Run the reassignment in one transaction. When two trips that overlap in time swap cars, a row-by-row update briefly puts both on one car, so start with SET CONSTRAINTS ALL DEFERRED. PostgreSQL then checks the DEFERRABLE constraint at commit, the moment the transaction's changes are saved. A single UPDATE that swaps both rows passes without it, because the check runs at the end of each statement.

Customers booked a type, not a plate number, so reassigning breaks no promise. That is one strong reason to sell car types.

How do one-way rentals change car inventory?

A one-way rental ends at a different hub, so the car's location changes after the trip. What a hub can offer on a future date depends on returns expected there, not just on cars parked there now.

The first version's Hub.carsOfType listed cars parked at the hub right now. That breaks for future dates. A sedan at the city hub today may be at the airport by Friday, because a one-way trip takes it there.

So change the question. Instead of asking which cars are here now, ask where each car will be at the pickup time. That place is the return hub of the car's last trip before then, or its current hub if it has none.

  • Each Reservation stores both a pickup hub and a return hub.
  • ReservationBook finds candidate cars by their expected hub at the pickup time, not by the hub's current list.
  • A new booking must not break the next trip already on that car, so the car must end up where that trip starts.
  • Staff can plan transfer drives: a reservation with no customer that moves a car between hubs.

The third rule is easy to miss. Say a sedan is free from Tuesday to Thursday, but its Thursday booking starts at the city hub. A one-way trip that leaves it at the airport on Wednesday would strand that booking.

This also breaks the busiest-moment rule from the last section. That rule only counts time, so with one-way trips you must also match each car's location, and simple counting no longer proves a plan exists.

Transfer drives fix the festival imbalance. Treat each one as a reservation with a staff member as the driver. Then the same overlap check protects it, and nobody can rent out a car that is being driven across the city.

The diagram below follows one sedan across three trips. It shows why the return hub of one trip decides where the next can begin.

One sedan's week across two hubs

Notice that each trip starts at the hub where the trip before it ended, so the transfer drive is what makes Thursday possible.

Diagram connections:

  • City hub, Monday 09:00 to One-way trip to airport (Picked up)
  • One-way trip to airport to Airport hub, Tuesday (Dropped off)
  • Airport hub, Tuesday to Transfer drive (Imbalance found)
  • Transfer drive to City hub, Wednesday (Arrives)
  • City hub, Wednesday to Thursday round trip (Picked up)
  • City hub, Monday 09:00: Sedan parked in the city
  • One-way trip to airport: Customer drops it at arrivals
  • Airport hub, Tuesday: Return hub of trip 1
  • Transfer drive: Staff drive it back to the city
  • City hub, Wednesday: Ready for Thursday's booking
  • Thursday round trip: Starts and ends at the city hub

What happens when a rental car comes back late?

A late car first eats into its own turnaround gap, leaving less time for cleaning. If it is later than the whole gap, it runs into the next trip on that car. The calendar cannot notice, because it only knows booked times.

So the service needs one more rule. When a car is overdue, save the new estimate in expected_return_at and keep the booked return_at for billing. The blocked range uses the later of the two. So PostgreSQL refuses the update as soon as the estimate plus the full gap overlaps the next trip, error 23P01 again.

That refusal is your signal. Move the next reservation to another free car of the same type, then save the estimate again. When the car arrives, the invoice compares the actual return time with the booked return_at.

A car going into maintenance triggers the same move for every future trip on it. Both cases reuse the reassign step from the capacity section, which is one more reason to sell types, not plates.

How do pricing and extra charges fit into the design?

Pricing fits as a small PricingPolicy object that Invoice asks for a number. Keep pricing rules out of Car and Reservation so they can change without touching booking code.

This service charges a daily rate from CarType, rounded up to whole days. On top come a late fee per extra hour, a fuel top-up charge and damage charges from a fixed list. Festival weeks have higher rates.

This is a good place for the strategy pattern, where you swap one rule object for another with the same method. A FestivalPricing policy and a NormalPricing policy both answer one question: what does this trip cost?

How do you explain car rental LLD in a system design interview?

Explain a car rental LLD by moving from requirements to classes, then the overlap rule, then concurrency. Spend most of your time on reservations, because that is where the hard rules live.

  1. Ask two questions

    Do customers book a type or a specific car? Can they return to a different hub? The answers shape everything else.

  2. List the classes

    Hub, CarType, Car, Customer, Reservation, ReservationBook and Invoice. Say which class owns which fact.

  3. Write the overlap rule

    Use half-open ranges and one line: each trip starts before the other ends. Add the turnaround gap on top.

  4. Show the reserve method

    Check and hold in one locked step. Throw a clear error when no car is free.

  5. Draw the states

    Car states and reservation states, with inspection as the only path from Returned to Closed.

  6. Scale it out

    Two servers break the in-memory lock. Add a PostgreSQL exclusion constraint or version numbers as the real guard.

  7. Name the extensions

    Reassigning cars at the busiest moment, one-way trips, transfer drives, late returns and pricing, each as a small change to the same classes.

Questions people ask

What classes are needed for a car rental system design?
Most designs need Hub, CarType, Car, Customer, Reservation, a booking manager such as ReservationBook, and Invoice. CarType holds what customers ask for, like category and rate. Car holds the real vehicle with its plate number and state. The booking manager is the only class that decides whether a car is free.
How do you check date overlap for a car booking?
Two bookings overlap when each one starts before the other ends. Store each trip as a half-open range, with the start included and the end excluded. Then a trip ending at 10:00 and another starting at 10:00 do not clash. Add any cleaning gap to the end before you run the check.
Should a customer book a specific car or a car type?
A good design usually sells a car type, then holds one real car behind the scenes. That lets the system move a booking to another car of the same type when a car breaks down, comes back late or blocks a better plan. Booking a specific plate is simpler to code but wastes fleet time.
How do you prevent double booking in a car rental LLD?
Make the availability check and the hold one indivisible step. Inside one Java program, a synchronized method or a lock is enough. With several servers, add a database guard, such as a PostgreSQL exclusion constraint on car and padded time range, or a version number checked on every save.
Which design patterns fit a car rental system?
A state machine fits cars and reservations: an enum plus one checked move method, as in this guide. The full state pattern, one class per state, pays off only when states behave very differently. The strategy pattern fits pricing, so festival and normal rates swap without touching booking code.
How are one-way rentals handled in the design?
Each reservation stores a pickup hub and a return hub. Availability then depends on where a car is expected to be at the pickup time, which is the return hub of its last earlier trip. The new trip must also leave the car where its next trip starts.
Is car rental LLD the same as hotel booking design?
They share the core idea: hold one unit for a time range without overlaps. Car rental adds things hotels do not have. Cars move between hubs on one-way trips, need inspection between uses, and get damaged on the road. A hotel room never ends up in another city.

Sources and further reading

Practise this with Lala

Practise