Projetando sistema de aluguel de carros: agenda, depósito e cancelamento

1. Visão Geral

Alugar um carro parece simples: cliente escolhe o veículo, escolhe a data, paga o depósito e retira. O mais difícil é garantir que um carro não seja reservado por duas pessoas no mesmo período. O sistema ainda precisa lidar corretamente quando o gateway de pagamento chama o webhook várias vezes, e deve suportar picos de tráfego durante feriados como Tét ou 30 de abril.

Este artigo cobre todo o design: requisitos, modelo de dados, API, fluxo de reserva, depósito e cancelamento. Cada desafio vem com código de exemplo usando Java 21, Spring Boot 3.x, PostgreSQL, Redis e Kafka. Ao terminar, você terá um framework de design pronto para qualquer sistema de reservas por período de tempo, mesmo que o objeto não seja um carro, mas sim um quarto, quadra de esportes ou sala de reunião.

2. Requisitos

2.1. Requisitos Funcionais

  • Cliente busca carros por cidade e período de tempo, por exemplo 2026-01-25 08:00 → 2026-01-28 20:00.
  • Cliente reserva (hold) um carro por 15 minutos para processar o pagamento do depósito.
  • Pagamento bem-sucedido muda a reserva para CONFIRMED. Expirados os 15 minutos sem pagamento, muda para EXPIRED e o carro é liberado.
  • Cliente que cancela recebe reembolso conforme política: 72 horas antes = 100%, 24 horas antes = 50%, próximo à hora = 0%.
  • Proprietário que cancela oferece reembolso integral.
  • Enviar notificações (SMS, push) quando o status da reserva muda.

2.2. Escala e Restrições

Métrica Normal Pico (Tét)
Número de carros 20.000 20.000
Buscas ~200 req/s ~3.000 req/s
Reservas ~10 req/s ~300 req/s
Taxa leitura/escrita ~20:1 ~10:1, concentradas em centenas de carros populares

A restrição mais crítica é nunca permitir double-booking. O sistema pode retornar erro “carro acaba de ser reservado”, mas nunca deve permitir dois contratos apontando para o mesmo carro no mesmo período. Portanto, dados de reserva devem ser fortemente consistentes. Página de busca pode aceitar dados com atraso de alguns segundos.

3. Modelo de Dados

PostgreSQL é a escolha certa porque tem o tipo tstzrange e exclusion constraint. Com essas duas ferramentas, o próprio banco previne períodos que se sobrepõem no mesmo carro.

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
);

Alguns pontos importantes:

  • no_overlap significa que não pode haver duas linhas com o mesmo car_id cujos period se sobrepõem (&&). A cláusula WHERE exclui reservas CANCELLED, EXPIRED, COMPLETED da restrição, então uma reserva cancelada não continua ocupando a agenda.
  • A extensão btree_gist é necessária para combinar o operador = em BIGINT com o operador && em range no mesmo índice GiST.
  • provider_txn_id UNIQUE é a base para idempotência ao processar webhooks de pagamento.
  • A tabela outbox serve para enviar eventos para Kafka sem perder mensagens (veja seção 8).

Nota: Carros precisam de tempo de limpeza e inspeção entre viagens. Armazene o period já com buffer adicional, por exemplo, 2 horas após a devolução. Não adicione buffer no código da aplicação, pois alguém sempre esquecerá em algum lugar.

4. Design de API

Método Endpoint Descrição
GET /cars?city=HN&from=…&to=… Buscar carros disponíveis
POST /bookings Reservar, retorna bookingId e link de pagamento
POST /payments/webhook Gateway de pagamento relata resultado
POST /bookings/{id}/cancel Cancelar viagem

POST /bookings recebe header Idempotency-Key. Quando o app retenta por falha de rede, requisição duplicada não cria nova reserva. Servidor retorna 409 Conflict se o carro já foi reservado, e 201 Created com:

{ "bookingId": 98123, "status": "HOLD", "holdExpiresAt": "2026-01-10T09:15:00+07:00",
  "paymentUrl": "https://pay.example/checkout/98123" }

5. Fluxo de Reserva

5.1. Prevenindo Double-Booking com Constraint

A abordagem comum mas errada é “SELECT para verificar disponibilidade e depois INSERT”. Duas requisições simultâneas veem o carro livre e ambas INSERT com sucesso. Ao invés disso, apenas INSERT e deixe o exclusion constraint decidir qual requisição vence.

Exemplo usando JdbcClient, que requer:

<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");
    }
}

O intervalo '[)' inclui o ponto inicial mas exclui o final. Assim, viagem terminando às 20:00 e outra começando às 20:00 não se sobrepõem. Quando a restrição é violada, PostgreSQL retorna SQLState 23P01, e Spring traduz em DataIntegrityViolationException. Convertemos para CarUnavailableException para que o controller mapeie para 409.

Quando dois clientes tentam reservar o carro 42 para o mesmo período, o log fica:

customer=1001 -> 201 Created  bookingId=98123 status=HOLD
customer=1002 -> 409 Conflict Car 42 is already booked in this period

5.2. Expiração de Reserva

Um job agendado converte reservas HOLD expiradas para EXPIRED. Reservas EXPIRED ficam fora da cláusula WHERE do constraint, então o carro é liberado imediatamente.

@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);
        }
    }
}

Ative @EnableScheduling em uma classe de configuração. O job rodando em paralelo em múltiplas instâncias continua seguro, pois UPDATE ... WHERE status = 'HOLD' é idempotente.

6. Depósito: Webhook Idempotente e Race com Expiração

O gateway de pagamento normalmente chama o webhook pelo menos uma vez, podendo chamar várias vezes. O webhook pode também chegar depois que a reserva expirou, por exemplo, se o cliente pagou no segundo 14:59 mas o banco processa lentamente. Ambas situações precisam de tratamento.

@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; // webhook duplicado, já foi processado
        }

        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));
        }
    }
}

O fluxo é:

  1. INSERT em payment com ON CONFLICT DO NOTHING. Se inserted == 0, webhook é duplicado e ignoramos.
  2. Atualizar status com update condicional (WHERE status = 'HOLD'). Essa instrução age como compare-and-set, sem lock explícito.
  3. Se update não afetar nenhuma linha, a reserva já EXPIRED e possivelmente pertence a outro cliente. Automaticamente solicitamos reembolso.

Todas operações em uma transação. Ou pagamento, status e evento são registrados, ou nada é.

7. Cancelamento e Reembolso

A política é lógica pura, separada em classe própria para facilitar testes:

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;
    }
}

Testes com JUnit 5 e 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);
    }
}

Na camada de serviço, cancelamento usa update condicional e RETURNING para obter dados de cálculo na mesma instrução:

UPDATE booking SET status = 'CANCELLED'
WHERE id = :id AND status = 'CONFIRMED'
RETURNING lower(period) AS pickup_at, deposit_amount;

Se nenhuma linha é retornada, a reserva já foi cancelada ou o cliente pegou o carro (ON_TRIP). API retorna 409. Se houver linha, calculamos refundAmount e registramos evento refund.requested no outbox dentro da mesma transação.

Nota: Use sempre o horário do servidor (Clock injetado) como cancelledAt, não do cliente. Diferença de minutos em torno da marca de 24 horas representa muito dinheiro.

8. Picos de Tráfego e Envio de Eventos Confiável

8.1. Separar Leitura da Escrita

Durante picos, a maioria do tráfego vem de buscas. Resultados podem ser cacheados em Redis com chave avail:{city}:{date} e TTL de 30-60 segundos. Dados em cache podem estar desatualizados, e isso é aceitável, pois o constraint no PostgreSQL é a fonte da verdade. No pior caso, cliente vê carro “disponível” e recebe 409 ao tentar reservar.

8.2. Prevenir Monopólio de Carros

Antes do Tét, alguns clientes reservam muitos carros e nunca pagam. Limite cada cliente a no máximo 3 reservas a cada 15 minutos com 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 é operação atômica, então múltiplas instâncias contam corretamente. A 4ª tentativa dentro de 15 minutos recebe false, e a API retorna 429 Too Many Requests.

8.3. Outbox para Kafka

Enviar para Kafka imediatamente dentro da transação é arriscado. Se DB faz commit mas Kafka falha, evento é perdido. Se fizer na ordem inversa, envia evento “fantasma”. O padrão transactional outbox resolve: registra evento na tabela outbox dentro da transação, e um relay lê e envia para 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 permite múltiplas instâncias de relay rodarem em paralelo sem pegar as mesmas linhas. O relay pode enviar evento duplicado, por exemplo, falhar após send mas antes de UPDATE. Portanto, consumer (serviço de notificação, serviço de reembolso) deve ser idempotente por bookingId.

9. Erros Comuns e Quando Não Aplicar

  • Verificar disponibilidade com SELECT e depois INSERT. Sempre tem race condition. Deixe o constraint fazer seu trabalho.
  • Lock com Redis no lugar de constraint. Lock pode expirar durante GC pause. Redis é apenas camada auxiliar, não fonte da verdade.
  • Armazenar horário sem fuso. Use TIMESTAMPTZ e OffsetDateTime, especialmente expandindo para vários países.
  • Reembolsar de forma síncrona na requisição de cancelamento. Gateways lentos deixam a API lenta. Envie via outbox.
  • Não use exclusion constraint quando calendário é fragmentado em múltiplos bancos por critério diferente de car_id. Constraint funciona apenas dentro de um banco. Se fragmentar, faça por car_id para que todas reservas de um carro estejam no mesmo lugar.

10. Conclusão

O aluguel de carros gira em torno de três pontos. Primeiro, deixe o banco bloquear double-booking com tstzrange e exclusion constraint. Segundo, mude status com update condicional combinado com chave única para lidar com webhooks duplicados e race com expiração. Terceiro, mova trabalho secundário como notificações e reembolso para fora do caminho crítico com transactional outbox e Kafka. Redis cuida da escalabilidade: cache de busca e limite de reservas. A fonte da verdade permanece em PostgreSQL.

Esse framework se aplica a qualquer sistema de reserva por período de tempo. Basta trocar car por quarto de hotel, quadra de esportes ou sala de consulta.

system-designspring-bootpostgresqlkafkaredis