System design for car rental: scheduling, deposits, and cancellations
1. Overview
Car rental sounds simple: customer picks a car, chooses dates, pays a deposit, and picks up the vehicle. The hardest part is ensuring no car gets double-booked at the same time slot. The system also needs to handle payment gateways calling webhooks multiple times, and must withstand traffic spikes around Tet holidays and April 30th.
This article walks through the full design: requirements, data model, APIs, hold flow, deposits, and cancellations. Each challenge comes with code examples using Java 21, Spring Boot 3.x, PostgreSQL, Redis, and Kafka. By the end, you’ll have a framework suitable for any time-slot-based booking system, whether the resource is a car, hotel room, sports court, or meeting room.
2. Requirements
2.1. Functional Requirements
- Customers search for cars by city and time range, e.g.,
2026-01-25 08:00→2026-01-28 20:00. - Customers hold a car for 15 minutes to complete the deposit payment.
- Once the deposit is paid, the booking moves to
CONFIRMED. If unpaid after 15 minutes, the booking expires and the car becomes available. - Customers who cancel get a refund per policy: 100% if cancelled 72+ hours before, 50% if 24+ hours before, 0% otherwise.
- If the host cancels, the customer gets a full refund.
- Send notifications (SMS, push) when booking status changes.
2.2. Scale and Constraints
| Metric | Normal | Peak (Tet) |
|---|---|---|
| Total cars | 20,000 | 20,000 |
| Search requests | ~200 req/s | ~3,000 req/s |
| Hold requests | ~10 req/s | ~300 req/s |
| Read/write ratio | ~20:1 | ~10:1, concentrated on a few hundred “hot” cars |
The most critical constraint is no double-booking ever. The system may return “car just got booked” errors, but two bookings must never point to the same car in overlapping time slots. Therefore, booking data must be strongly consistent. The search page is fine with data that’s a few tens of seconds stale.
3. Data Model
PostgreSQL is the right choice because it has the tstzrange type and exclusion constraints. With these two features, the database itself prevents overlapping time ranges on the same car.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE car (
id BIGSERIAL PRIMARY KEY,
plate VARCHAR(20) NOT NULL UNIQUE,
model VARCHAR(100) NOT NULL,
city VARCHAR(50) NOT NULL,
daily_price BIGINT NOT NULL
);
CREATE TABLE booking (
id BIGSERIAL PRIMARY KEY,
car_id BIGINT NOT NULL REFERENCES car(id),
customer_id BIGINT NOT NULL,
period TSTZRANGE NOT NULL,
status VARCHAR(20) NOT NULL,
deposit_amount BIGINT NOT NULL,
hold_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT no_overlap EXCLUDE USING gist (
car_id WITH =,
period WITH &&
) WHERE (status IN ('HOLD', 'CONFIRMED', 'ON_TRIP'))
);
CREATE TABLE payment (
id BIGSERIAL PRIMARY KEY,
booking_id BIGINT NOT NULL REFERENCES booking(id),
provider_txn_id VARCHAR(64) NOT NULL UNIQUE,
type VARCHAR(10) NOT NULL, -- DEPOSIT | REFUND
amount BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
A few notes:
no_overlapmeans no two rows with the samecar_idcan have overlappingperiods (&&). TheWHEREclause excludes bookings with statusCANCELLED,EXPIRED, orCOMPLETEDfrom the constraint, so a cancelled booking no longer blocks the calendar.- The
btree_gistextension is needed to combine equality onBIGINTwith range overlap (&&) in a single GiST index. provider_txn_id UNIQUEis the foundation of idempotency when processing payment webhooks.- The
outboxtable enables sending events to Kafka reliably without message loss (see section 7).
Note: Cars need cleaning and inspection time between trips. Store the
periodwith buffer already added, e.g., 2 hours after the return time. Don’t add buffer in application code—someone will eventually forget to add it somewhere.
4. API Design
| Method | Endpoint | Description |
|---|---|---|
GET |
/cars?city=HN&from=…&to=… |
Search available cars |
POST |
/bookings |
Hold a car, returns bookingId and payment link |
POST |
/payments/webhook |
Payment gateway reports result |
POST |
/bookings/{id}/cancel |
Cancel a booking |
POST /bookings accepts an Idempotency-Key header. When the mobile app retries due to network flakiness, duplicate requests won’t create additional bookings. The server returns 409 Conflict if the car is already booked, and 201 Created with the following body on successful hold:
{ "bookingId": 98123, "status": "HOLD", "holdExpiresAt": "2026-01-10T09:15:00+07:00",
"paymentUrl": "https://pay.example/checkout/98123" }
5. Hold Flow
5.1. Preventing Double-Booking via Constraint
The familiar but wrong approach is “SELECT to check availability, then INSERT.” Two requests arriving simultaneously both see the car as free and both succeed. Instead, just INSERT and let the exclusion constraint decide which request wins.
Here’s an example using JdbcClient. First, add these dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
@Service
public class BookingService {
private final JdbcClient jdbc;
public BookingService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public long hold(long carId, long customerId,
OffsetDateTime from, OffsetDateTime to, long deposit) {
try {
return jdbc.sql("""
INSERT INTO booking(car_id, customer_id, period, status,
deposit_amount, hold_expires_at)
VALUES (:carId, :customerId,
tstzrange(CAST(:from AS timestamptz), CAST(:to AS timestamptz), '[)'),
'HOLD', :deposit, now() + interval '15 minutes')
RETURNING id
""")
.param("carId", carId)
.param("customerId", customerId)
.param("from", from)
.param("to", to)
.param("deposit", deposit)
.query(Long.class)
.single();
} catch (DataIntegrityViolationException e) {
throw new CarUnavailableException(carId);
}
}
}
public class CarUnavailableException extends RuntimeException {
public CarUnavailableException(long carId) {
super("Car " + carId + " is already booked in this period");
}
}
The '[)' range is inclusive on the left and exclusive on the right. This means a trip ending at 20:00 and another starting at 20:00 don’t overlap. When the constraint is violated, PostgreSQL returns SQLState 23P01, which Spring translates to DataIntegrityViolationException. We convert it to CarUnavailableException so the controller maps it to 409.
When two customers try to hold car 42 for the same time slot, the logs look like this:
customer=1001 -> 201 Created bookingId=98123 status=HOLD
customer=1002 -> 409 Conflict Car 42 is already booked in this period
5.2. Hold Expiration
A periodic job converts expired HOLD bookings to EXPIRED. Since EXPIRED bookings fall outside the constraint’s WHERE condition, the car becomes available immediately.
@Component
public class HoldExpiryJob {
private final JdbcClient jdbc;
public HoldExpiryJob(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Scheduled(fixedDelay = 30_000)
public void expireHolds() {
int expired = jdbc.sql("""
UPDATE booking SET status = 'EXPIRED'
WHERE status = 'HOLD' AND hold_expires_at < now()
""").update();
if (expired > 0) {
System.out.println("Expired holds: " + expired);
}
}
}
Remember to enable @EnableScheduling on a configuration class. This job is safe to run across multiple instances because the UPDATE ... WHERE status = 'HOLD' query is inherently idempotent.
6. Deposit: Idempotent Webhooks and Race with Expiry
Payment gateways typically call webhooks at least once, meaning possibly two or three times. Additionally, webhooks may arrive after the hold has already expired, for example if the customer pays at 14:59 but the bank processes slowly. Both scenarios must be handled.
@Service
public class PaymentService {
private final JdbcClient jdbc;
private final OutboxWriter outbox;
public PaymentService(JdbcClient jdbc, OutboxWriter outbox) {
this.jdbc = jdbc;
this.outbox = outbox;
}
@Transactional
public void onDepositPaid(long bookingId, String providerTxnId, long amount) {
int inserted = jdbc.sql("""
INSERT INTO payment(booking_id, provider_txn_id, type, amount)
VALUES (:bookingId, :txn, 'DEPOSIT', :amount)
ON CONFLICT (provider_txn_id) DO NOTHING
""")
.param("bookingId", bookingId).param("txn", providerTxnId).param("amount", amount)
.update();
if (inserted == 0) {
return; // duplicate webhook, already processed
}
int confirmed = jdbc.sql("""
UPDATE booking SET status = 'CONFIRMED', hold_expires_at = NULL
WHERE id = :id AND status = 'HOLD'
""").param("id", bookingId).update();
if (confirmed == 1) {
outbox.write("booking.confirmed", Map.of("bookingId", bookingId));
} else {
outbox.write("refund.requested", Map.of("bookingId", bookingId, "amount", amount));
}
}
}
Processing happens as follows:
- INSERT into
paymentwithON CONFLICT DO NOTHING. Ifinserted == 0, this is a duplicate webhook, so skip it. - Update status with a conditional update (
WHERE status = 'HOLD'). This acts as compare-and-swap, so no explicit lock is needed. - If the update matches no rows, the hold has already
EXPIREDand the car may belong to someone else. In this case, automatically issue a refund instead of trying to “resurrect” the booking.
All operations are within a transaction. Either the payment, booking status, and outbox event all get written, or none of them do.
7. Cancellation and Refunds
The refund policy is pure logic, so it’s separated into its own class for easy testing:
public final class CancellationPolicy {
private CancellationPolicy() {}
public static long refundAmount(long deposit, OffsetDateTime pickupAt,
OffsetDateTime cancelledAt, boolean cancelledByHost) {
if (cancelledByHost) {
return deposit;
}
long hoursBefore = Duration.between(cancelledAt, pickupAt).toHours();
if (hoursBefore >= 72) return deposit;
if (hoursBefore >= 24) return deposit / 2;
return 0;
}
}
Test with JUnit 5 and AssertJ:
class CancellationPolicyTest {
private final OffsetDateTime pickup = OffsetDateTime.parse("2026-01-25T08:00:00+07:00");
@Test
void refundsFullWhenCancelledThreeDaysBefore() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(72), false))
.isEqualTo(2_000_000);
}
@Test
void refundsHalfWhenCancelledOneDayBefore() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(30), false))
.isEqualTo(1_000_000);
}
@Test
void refundsNothingWhenCancelledLastMinute() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(2), false))
.isZero();
}
@Test
void refundsFullWhenHostCancels() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(1), true))
.isEqualTo(2_000_000);
}
}
At the service layer, cancellation also uses conditional update and RETURNING to fetch pricing data in a single statement:
UPDATE booking SET status = 'CANCELLED'
WHERE id = :id AND status = 'CONFIRMED'
RETURNING lower(period) AS pickup_at, deposit_amount;
If the statement returns no rows, the booking was already cancelled or the customer already picked up the car (ON_TRIP). The API returns 409. If rows are returned, calculate refundAmount and write a refund.requested event to the outbox in the same transaction.
Note: Always use the server timestamp (
Clockinjected) forcancelledAt, never the client’s time. Being off by a few minutes around the 24-hour boundary can differ by millions.
8. Peak Load and Reliable Event Publishing
8.1. Separate Reads from Writes
During peak season, most load comes from searches. Search results can be cached in Redis with key avail:{city}:{date} and TTL 30–60 seconds. Stale cache data is acceptable because PostgreSQL’s constraint is the source of truth. In the worst case, a customer sees a car as “available” but gets 409 when holding it.
8.2. Anti-Hoarding
Before Tet, some customers (or bots) hold many cars without paying. You can limit each customer to at most 3 holds per 15 minutes using Redis:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
public class HoldRateLimiter {
private final StringRedisTemplate redis;
public HoldRateLimiter(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryAcquire(long customerId) {
String key = "hold-limit:" + customerId;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofMinutes(15));
}
return count != null && count <= 3;
}
}
increment is atomic, so multiple instances calling simultaneously still count correctly. The 4th attempt in a 15-minute window gets false, and the API returns 429 Too Many Requests.
8.3. Outbox to Kafka
Publishing to Kafka inside a transaction is risky. If the DB commits successfully but Kafka fails, the event is lost. Doing it the other way, we send a “phantom” event for a change that was never persisted. The transactional outbox pattern solves this: write events to the outbox table within the same transaction, then a relay reads the table and pushes to Kafka.
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {
private final JdbcClient jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 1_000)
@Transactional
public void publish() {
var rows = jdbc.sql("""
SELECT id, topic, payload::text AS payload FROM outbox
WHERE published_at IS NULL ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED
""").query().listOfRows();
for (var row : rows) {
kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
jdbc.sql("UPDATE outbox SET published_at = now() WHERE id = :id")
.param("id", row.get("id")).update();
}
}
}
FOR UPDATE SKIP LOCKED allows multiple relay instances to run in parallel without picking the same rows. The relay can still send duplicate events, e.g., if it crashes after send but before UPDATE. Therefore, consumers (notification service, refund service) must be idempotent by bookingId.
9. Common Pitfalls and When Not to Apply
- Checking availability with SELECT then INSERT. This always has race conditions. Let the constraint do its job.
- Using Redis locks instead of constraints. Locks can expire mid-operation, e.g., during GC pauses. Redis should be a secondary safeguard, not the source of truth.
- Storing time without timezone. Use
TIMESTAMPTZandOffsetDateTime, especially if you expand to multiple countries later. - Refunding synchronously in the cancel request. Slow payment gateways will make the cancel API slow. Push refunds through the outbox.
- Don’t use exclusion constraints if the calendar is sharded across multiple databases by criteria other than
car_id. Constraints only work within a single database. If you need to shard, shard bycar_idso all bookings for a car stay together.
10. Conclusion
The car rental problem revolves around three key points. First, let the database prevent double-booking using tstzrange and exclusion constraints. Second, transition state with conditional updates combined with unique keys to handle duplicate webhooks and races with expiring holds. Third, separate auxiliary work like notifications and refunds from the main flow using transactional outbox and Kafka. Redis handles the load: caching searches and rate-limiting holds. PostgreSQL remains the source of truth.
This design framework applies to most time-slot-based booking systems. You just swap car for hotel room, sports court, or doctor appointment.