Conception d'un système de location : calendrier, dépôt et annulation

1. Aperçu

La location de voiture en libre-service semble simple : le client choisit une voiture, une date, verse un dépôt et retire la voiture. Le plus difficile est de garantir qu’une voiture ne soit pas réservée par deux personnes pour le même horaire. Le système doit aussi gérer correctement les appels webhook répétés de la passerelle de paiement, et supporter les pics de trafic avant les vacances du Têt ou le 30 avril.

Cet article couvre l’ensemble de la conception : exigences, modèle de données, API, flux de réservation, dépôt et annulation. Chaque point difficile s’accompagne d’exemples de code en Java 21, Spring Boot 3.x, PostgreSQL, Redis et Kafka. À la fin, vous disposerez d’une architecture applicable à tout système de réservation par plage horaire, qu’il s’agisse de voitures, de chambres, de terrains de sport ou de salles de réunion.

2. Exigences

2.1. Exigences fonctionnelles

  • Le client cherche une voiture par ville et plage horaire, par exemple 2026-01-25 08:00 → 2026-01-28 20:00.
  • Le client réserve (hold) une voiture pendant 15 minutes pour effectuer le paiement du dépôt.
  • Une fois le dépôt payé, la réservation passe à CONFIRMED. Si elle n’est pas payée après 15 minutes, elle passe à EXPIRED et la voiture est libérée.
  • Le client qui annule a droit à un remboursement selon la politique : 100 % si annulé plus de 72 heures avant, 50 % si plus de 24 heures avant, 0 % sinon.
  • Si le propriétaire annule la réservation, le client est remboursé de la totalité du dépôt.
  • Envoyer des notifications (SMS, push) quand le statut de la réservation change.

2.2. Échelle et contraintes

Métrique Normal Pic (Têt)
Nombre de voitures 20.000 20.000
Requêtes de recherche ~200 req/s ~3.000 req/s
Réservations (hold) ~10 req/s ~300 req/s
Ratio lecture/écriture ~20:1 ~10:1, concentré sur quelques centaines de voitures « chaudes »

La contrainte la plus importante est jamais de surréservation. Le système est autorisé à retourner une erreur « la voiture vient d’être réservée », mais ne doit jamais laisser deux contrats pointer vers une même voiture pour le même créneau horaire. C’est pourquoi les données de réservation doivent être fortement cohérentes. La page de recherche peut afficher des données de quelques dizaines de secondes anciennes.

3. Modèle de données

PostgreSQL est le choix approprié car il dispose du type tstzrange et des exclusion constraints. Avec ces deux outils, la base de données elle-même bloquera les deux plages horaires qui se chevauchent sur une même voiture.

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

Quelques points importants :

  • no_overlap signifie qu’il ne peut y avoir deux lignes avec le même car_id et des period qui se chevauchent (&&). La clause WHERE exclut les réservations CANCELLED, EXPIRED, COMPLETED de la contrainte, donc une réservation annulée ne bloque plus le calendrier.
  • L’extension btree_gist est nécessaire pour combiner l’opération = sur BIGINT avec l’opération && sur les ranges dans un même index GiST.
  • provider_txn_id UNIQUE est la base de l’idempotence lors du traitement des webhooks de paiement.
  • La table outbox assure l’envoi des événements vers Kafka sans perte (voir section 7).

Remarque : Une voiture a besoin de temps de nettoyage et de vérification entre deux trajets. Vous devez stocker period avec un buffer ajouté, par exemple 2 heures après l’heure de retour. Ne pas ajouter le buffer dans le code de l’application, car tôt ou tard quelqu’un oubliera de l’ajouter.

4. Conception de l’API

Méthode Point de terminaison Description
GET /cars?city=HN&from=…&to=… Trouver des voitures disponibles
POST /bookings Réserver, retourner bookingId et le lien de paiement
POST /payments/webhook La passerelle de paiement rapporte le résultat
POST /bookings/{id}/cancel Annuler

POST /bookings reçoit l’en-tête Idempotency-Key. Si l’appli mobile réessaie à cause d’un problème réseau, la requête répétée ne créera pas de nouvelle réservation. Le serveur retourne 409 Conflict si la voiture a déjà été réservée, et 201 Created avec le corps suivant lors d’une réservation réussie :

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

5. Flux de réservation

5.1. Prévention des surréservations avec constraint

L’approche familière mais erronée est « SELECT pour vérifier la disponibilité, puis INSERT ». Deux requêtes arrivant au même moment voient toutes deux la voiture libre, et les deux INSERT réussissent. À la place, insérez simplement et laissez l’exclusion constraint décider quelle requête l’emporte.

Voici un exemple utilisant JdbcClient avec les dépendances suivantes :

<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("La voiture " + carId + " est déjà réservée pour cette période");
    }
}

La plage '[)' comprend le point de départ mais pas le point final. Grâce à cela, un trajet se terminant à 20:00 et un trajet commençant à 20:00 ne sont pas considérés comme se chevauchant. Lorsque la contrainte est violée, PostgreSQL retourne SQLState 23P01, et Spring traduit cette erreur en DataIntegrityViolationException. Nous la convertissons en CarUnavailableException pour que le contrôleur la mappe à 409.

Quand deux clients réservent la même voiture 42 pour le même créneau horaire, le journal ressemblera à :

customer=1001 -> 201 Created  bookingId=98123 status=HOLD
customer=1002 -> 409 Conflict La voiture 42 est déjà réservée pour cette période

5.2. Expiration des réservations

Un job périodique convertit les réservations HOLD expirées en EXPIRED. Les réservations EXPIRED ne correspondent pas à la clause WHERE de la contrainte, donc la voiture est libérée immédiatement.

@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("Réservations expirées : " + expired);
        }
    }
}

N’oubliez pas d’activer @EnableScheduling sur une classe de configuration. Ce job peut s’exécuter en parallèle sur plusieurs instances de façon sûre, car la requête UPDATE ... WHERE status = 'HOLD' est déjà idempotente.

6. Dépôt : webhook idempotent et race avec expiration

La passerelle de paiement appelle généralement le webhook au moins une fois, ce qui signifie qu’elle peut l’appeler deux ou trois fois. En outre, le webhook peut arriver après l’expiration de la réservation, par exemple si le client paie à la 59ᵉ seconde mais que la banque traite lentement. Les deux scénarios doivent être gérés.

@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 dupliqué, déjà traité
        }

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

Le traitement se déroule comme suit :

  1. Insérer dans payment avec ON CONFLICT DO NOTHING. Si inserted == 0, c’est un webhook dupliqué, nous le ignorons.
  2. Changer le statut avec une mise à jour conditionnelle (WHERE status = 'HOLD'). Cette instruction agit comme un compare-and-set, donc aucun verrou explicite n’est nécessaire.
  3. Si la mise à jour ne touche aucune ligne, le hold a déjà EXPIRED et la voiture appartient peut-être à quelqu’un d’autre. Nous émettons alors automatiquement un remboursement, sans essayer de « ressusciter » la réservation.

Toutes les opérations se déroulent au sein d’une transaction. Soit le paiement, le statut de la réservation et l’événement outbox sont tous écrits, soit rien n’est écrit.

7. Annulation et remboursement

La politique de remboursement est une logique pure, nous la séparons donc dans une classe dédiée pour la tester facilement :

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 avec JUnit 5 et 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);
    }
}

Au niveau du service, l’annulation utilise aussi une mise à jour conditionnelle et RETURNING pour récupérer les données de calcul du remboursement dans la même instruction :

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

Si l’instruction ne retourne aucune ligne, la réservation a été annulée précédemment ou le client a récupéré la voiture (ON_TRIP). L’API retourne alors 409. S’il y a une ligne retournée, nous calculons refundAmount puis écrivons l’événement refund.requested dans la boîte sortante, dans la même transaction.

Remarque : Utilisez toujours le serveur (Clock injecté) pour cancelledAt, ne prenez pas le temps du client. Quelques minutes de différence autour de la marque 24 heures peuvent représenter des millions de dongs.

8. Pics de charge et événements fiables

8.1. Séparation lecture/écriture

En haute saison, la plupart du trafic provient des opérations de recherche. Les résultats de recherche peuvent être cachés dans Redis avec la clé avail:{city}:{date} et un TTL de 30–60 secondes. Les données en cache peuvent être anciennes, et c’est acceptable car la contrainte PostgreSQL reste la source de vérité définitive. Au pire, le client voit la voiture « disponible » puis reçoit 409 lors de la réservation.

8.2. Prévention de l’accaparement

Avant le Têt, certains clients (ou bots) réservent en masse sans payer. Vous pouvez limiter chaque client à un maximum de 3 réservations par 15 minutes avec 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 est une opération atomique, donc plusieurs instances l’appelant simultanément comptent toujours correctement. La 4ᵉ tentative dans la fenêtre de 15 minutes recevra false, et l’API retournera 429 Too Many Requests.

8.3. Outbox vers Kafka

Envoyer vers Kafka immédiatement dans une transaction est très risqué. Si le commit DB réussit mais que Kafka échoue, l’événement est perdu. Si l’ordre inverse, nous envoions un événement « fantôme » pour une modification jamais enregistrée. Le pattern transactional outbox résout ce problème : écrire l’événement dans la table outbox au sein de la même transaction, puis un relais lit cette table et l’envoie vers 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 permet à plusieurs instances de relais de s’exécuter en parallèle sans acquérir les mêmes lignes. Le relais peut toujours envoyer un événement en double, par exemple en cas de crash après send mais avant UPDATE. C’est pourquoi le consommateur (service de notification, service de remboursement) doit être idempotent sur bookingId.

9. Erreurs courantes et quand ne pas appliquer

  • Vérifier la disponibilité avec SELECT puis INSERT. Cette approche a toujours une condition de course. Laissez la contrainte faire son travail.
  • Verrouiller avec Redis au lieu d’une contrainte. Le verrou peut expirer à mi-parcours, par exemple lors d’une pause GC. Redis devrait seulement être une couche de protection auxiliaire, pas la source de vérité.
  • Stocker le temps sans fuseau horaire. Utilisez TIMESTAMPTZ et OffsetDateTime, surtout si vous envisagez de vous étendre à plusieurs pays par la suite.
  • Rembourser de manière synchrone dans la requête d’annulation. Une passerelle de paiement lente ralentira l’API d’annulation. Poussez le remboursement via la boîte sortante.
  • Ne pas utiliser l’exclusion constraint quand le calendrier est fragmenté sur plusieurs bases de données selon un critère autre que car_id. La contrainte n’est valide que dans une seule base de données. Si vous devez fragmenter, fragmentez par car_id pour que tous les bookings d’une voiture se trouvent au même endroit.

10. Conclusion

Le problème de la location de voiture en libre-service tourne autour de trois points clés. Premièrement, laisser la base de données bloquer la surréservation avec tstzrange et l’exclusion constraint. Deuxièmement, changer le statut avec une mise à jour conditionnelle combinée à une clé unique pour gérer les webhooks dupliqués et les races avec les holds expirés. Troisièmement, séparer les tâches auxiliaires comme les notifications et les remboursements du flux principal avec une boîte sortante transactionnelle et Kafka. Redis prend en charge la charge : mise en cache des recherches et limitation des réservations. La source de vérité reste dans PostgreSQL.

Cette architecture s’applique à la plupart des systèmes de réservation par plage horaire. Il vous suffit de remplacer car par une chambre d’hôtel, un terrain de sport ou un calendrier de consultation médicale.

conception-systemespring-bootpostgresqlkafkaredis