Du composant à la transaction : actions, relations et données sans magie cachée

Du clic au contrat API, au domaine et à la transaction : concurrence optimiste, outbox et test E2E sur une véritable base relationnelle.

L’article complet est disponible ci-dessous. Activez JavaScript pour les quiz et les simulations.

Atelier système : du clic au COMMIT

Le bouton n’enregistre pas les données. Il déclenche un contrat porté par tout le système.

Les défauts difficiles naissent souvent entre les composants : l’interface considère l’opération terminée, l’API commence à peine et la base refuse l’écriture parce qu’un autre utilisateur a modifié le même enregistrement.

Prenons un cas concret : l’opérateur clique sur « Lancer la réalisation ». Nous suivrons composant, HTTP, couche applicative, domaine et base relationnelle. Puis nous casserons volontairement le parcours pour voir où le système doit refuser.

Carte du parcours

Cinq frontières de responsabilité

clic → validation → règle → transaction → réponse
01 Composant Recueille l’intention et affiche l’état de l’opération.
02 Action HTTP Transporte un contrat explicite et un identifiant de requête.
03 Modèle métier Protège les règles incontournables.
04 Base relationnelle Assure atomicité, relations et unicité.
05 Nouvel état de l’interface Montre le résultat confirmé par le serveur.
01

Composant

L’interface traduit l’intention, elle ne remplace pas la base

Le composant connaît le formulaire, le bouton et le message. Il n’a pas à connaître l’ordre des écritures ni à décider du quota restant de l’entreprise. Ces règles doivent aussi agir lorsqu’un mobile, une intégration ou un worker appelle l’API.

Le composant suivant bloque le double clic, transmet la version et ne remplace l’état visible qu’après la réponse. L’animation peut être immédiate, mais le résultat métier n’est confirmé qu’après l’API.

export class WorkOrderActions {
  private readonly api = inject(WorkOrdersApi);
  readonly pending = signal(false);
  readonly error = signal<string | null>(null);

  async start(order: WorkOrderView): Promise<void> {
    if (this.pending()) return;

    this.pending.set(true);
    this.error.set(null);

    try {
      const result = await firstValueFrom(
        this.api.start(order.id, {
          expectedVersion: order.version,
          requestId: crypto.randomUUID()
        })
      );

      order.status = result.status;      // stan potwierdzony przez API
      order.version = result.version;
    } catch (error) {
      this.error.set(mapProblemDetails(error));
    } finally {
      this.pending.set(false);
    }
  }
}

Décision d’architecture

L’utilisateur clique deux fois à cause du réseau lent. Que faites-vous ?

Je désactive le bouton et considère le problème résolu

Je bloque l’interface et impose l’idempotence dans l’API et la base

Choisissez et découvrez la conséquence.

02

Action

Un endpoint mince, un contrat réfléchi

L’endpoint gère la frontière du protocole : authentification, autorisation, validation, erreurs et statut de réponse. Y accumuler le métier ferait diverger la même règle entre API, worker et administration.

app.MapPost("/api/work-orders/{id:guid}/start", async (
    Guid id,
    StartWorkOrderRequest body,
    ClaimsPrincipal user,
    StartWorkOrderHandler handler,
    CancellationToken ct) =>
{
    var tenantId = user.RequireTenantId();
    var result = await handler.HandleAsync(
        new StartWorkOrderCommand(
            tenantId,
            id,
            body.ExpectedVersion,
            body.RequestId),
        ct);

    return result.Match(
        started => Results.Ok(started),
        conflict => Results.Conflict(new {
            code = "work_order_changed",
            message = "L’état de l’ordre de travail a changé. Actualisez les données."
        }),
        invalid => Results.ValidationProblem(invalid.Errors));
})
.RequireAuthorization("WorkOrderOperator")
.RequireRateLimiting("work-order-write");
400 Entrée invalide

requestId absent, version incorrecte ou données hors contrat.

403 Droit absent

L’utilisateur est connecté mais ne peut pas lancer cette mission.

409 Conflit d’état

L’enregistrement a changé depuis la lecture ou cette intention a déjà été traitée.

200 État enregistré

La réponse contient la nouvelle version et le statut confirmé par la base.

03

Modèle métier

La règle métier a un endroit unique pour refuser

Supposons qu’une mission ne démarre qu’après acceptation du périmètre et avant son achèvement. Cette règle appartient au modèle : elle décrit une transition autorisée, indépendamment du contrôleur, du composant ou du SQL.

public sealed class WorkOrder
{
    public Guid Id { get; private set; }
    public WorkOrderStatus Status { get; private set; }
    public bool ScopeApproved { get; private set; }
    public int Version { get; private set; }

    public DomainResult Start(DateTimeOffset now)
    {
        if (!ScopeApproved)
            return DomainResult.Invalid("scope_not_approved");

        if (Status == WorkOrderStatus.Completed)
            return DomainResult.Invalid("work_order_completed");

        if (Status == WorkOrderStatus.InProgress)
            return DomainResult.NoChange();

        Status = WorkOrderStatus.InProgress;
        Version += 1;
        AddDomainEvent(new WorkOrderStarted(Id, now));
        return DomainResult.Changed();
    }
}

Remarquez NoChange(). Répéter la même intention valide n’est pas forcément une erreur. Le système peut renvoyer l’état actuel s’il prouve qu’il s’agit de la même opération. La reprise après timeout n’exige alors pas un nettoyage manuel.

04

Base relationnelle

Les relations constituent la dernière protection des données

La validation applicative fournit une erreur lisible, mais la base doit empêcher deux opérations concurrentes de violer l’unicité. Le schéma exprime les invariants : clés étrangères, champs requis, plages, unicité et jeton de concurrence.

WorkOrders Id PK TenantId FK Status Version
1
N
WorkOrderRequests Id PK WorkOrderId FK RequestId UNIQUE ResultJson
1
N
OutboxMessages Id PK AggregateId Type ProcessedAt
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<WorkOrder>(entity =>
    {
        entity.HasKey(x => x.Id);
        entity.Property(x => x.Version).IsConcurrencyToken();
        entity.HasIndex(x => new { x.TenantId, x.Status });
    });

    modelBuilder.Entity<WorkOrderRequest>(entity =>
    {
        entity.HasKey(x => x.Id);
        entity.HasIndex(x => new { x.TenantId, x.RequestId })
              .IsUnique();
        entity.HasOne(x => x.WorkOrder)
              .WithMany()
              .HasForeignKey(x => x.WorkOrderId)
              .OnDelete(DeleteBehavior.Restrict);
    });
}

Course entre deux requêtes

Les deux processus ont constaté l’absence de requestId. Qu’est-ce qui empêche le doublon ?

Un AnyAsync() supplémentaire avant l’écriture

Un index unique sur TenantId + RequestId

Choisissez un mécanisme fiable en concurrence.

05

Transaction

COMMIT fixe le moment où l’on peut annoncer « terminé »

Si le changement comprend mission, tentative idempotente et message outbox, ils doivent tous être enregistrés ou aucun. Un appel à SaveChangesAsync est transactionnel pour les fournisseurs relationnels habituels. Si plusieurs écritures sont nécessaires, contrôlez explicitement la transaction et testez-la avec le fournisseur utilisé.

public async Task<StartWorkOrderView> HandleAsync(
    StartWorkOrderCommand command,
    CancellationToken ct)
{
    var previous = await db.WorkOrderRequests.SingleOrDefaultAsync(
        x => x.TenantId == command.TenantId
          && x.RequestId == command.RequestId,
        ct);

    if (previous is not null)
        return previous.ReadResult();

    var order = await db.WorkOrders.SingleAsync(
        x => x.Id == command.WorkOrderId
          && x.TenantId == command.TenantId,
        ct);

    if (order.Version != command.ExpectedVersion)
        throw new WorkOrderConflictException();

    order.Start(clock.UtcNow);
    var view = StartWorkOrderView.From(order);

    db.WorkOrderRequests.Add(
        WorkOrderRequest.Completed(command, view));
    db.OutboxMessages.Add(
        OutboxMessage.From(new WorkOrderStarted(order.Id, clock.UtcNow)));

    await db.SaveChangesAsync(ct);
    return view;
}

Cela ne signifie pas que l’e-mail est envoyé, mais que l’intention l’est durablement. Après COMMIT, le worker lit l’outbox, appelle le service externe et marque le message. Une panne du prestataire n’efface pas l’enregistrement.

Effet externe

Quand envoyer l’e-mail « réalisation lancée » ?

Avant SaveChanges pour aller plus vite

Après COMMIT via une transactional outbox

Choisissez le moment qui évite une fausse confirmation.

06

Concurrence

409 signale honnêtement une décision prise sur un ancien état

A ouvre la mission en version 7. B modifie le périmètre et enregistre la version 8. Si A lance sur son ancien écran, il ne doit pas écraser silencieusement B. Le jeton de concurrence n’autorise l’écriture que si la version correspond encore.

T0 A et B lisent la version 7

Les deux interfaces montrent le même état.

T1 B enregistre sa modification

La base passe à la version 8.

T2 A envoie expectedVersion = 7

UPDATE ne trouve aucune version correspondante.

T3 L’API renvoie 409

L’interface actualise et demande une nouvelle décision.

try
{
    await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
    throw new WorkOrderConflictException(
        "L’ordre de travail a été modifié par un autre opérateur.");
}
07

Test E2E

Au-delà du 200, vérifiez la trace de l’opération

Le test doit appeler le vrai endpoint, traverser autorisation, handler et fournisseur relationnel, puis vérifier JSON et tables. Un mock ne révèle pas une violation d’index, un défaut d’isolation ou de mapping.

[Fact]
public async Task Start_is_idempotent_and_creates_one_outbox_message()
{
    await using var app = new ApiFactory()
        .WithPostgresContainer()
        .WithOperator("tenant-a");
    var client = app.CreateClient();
    var requestId = Guid.NewGuid();

    var first = await client.PostAsJsonAsync(
        $"/api/work-orders/{Seed.OrderId}/start",
        new { expectedVersion = 1, requestId });
    var retry = await client.PostAsJsonAsync(
        $"/api/work-orders/{Seed.OrderId}/start",
        new { expectedVersion = 1, requestId });

    first.EnsureSuccessStatusCode();
    retry.EnsureSuccessStatusCode();

    await app.InDatabase(async db =>
    {
        Assert.Equal(1, await db.WorkOrderRequests.CountAsync());
        Assert.Equal(1, await db.OutboxMessages.CountAsync());
        Assert.Equal(WorkOrderStatus.InProgress,
            await db.WorkOrders.Select(x => x.Status).SingleAsync());
    });
}
Scénario HTTP attendu État de la base Réaction de l’interface
Premier lancement valide 200 Statut + requête + outbox Affiche le nouvel état
Reprise du même requestId 200 Aucune seconde écriture Affiche le même résultat
Ancienne version 409 Aucun changement Actualise et explique le conflit
Droit absent 403 Aucun changement Ne révèle aucune donnée de l’opération
Panne du fournisseur d’e-mails 200 Outbox en attente de reprise N’annule pas l’opération réussie

Simulation de recette

Faites traverser tout le pipeline à la modification

Lancer le test du parcours

Interface API Domaine PostgreSQL Outbox

Prêt à tester : idempotence, conflit de version, transaction et effet après COMMIT.

Definition of Done

La relation entre couches est saine si vous pouvez répondre oui

Composant

L’attente et l’erreur sont-elles affichées sans prétendre à un succès ?

Contrat

La requête porte-t-elle requestId et version lorsque nécessaire ?

Domaine

La règle fonctionne-t-elle quel que soit l’appelant ?

Base

Clés étrangères, index et jeton de concurrence protègent-ils les données en course ?

Effets

E-mail, webhook et file sont-ils déclenchés après une écriture confirmée ?

Recette

Le test contrôle-t-il réponse et trace réelle en base relationnelle ?

Le point essentiel

Les couches donnent un responsable à chaque décision, pas seulement davantage de fichiers.

Le composant dialogue avec l’utilisateur ; l’endpoint gère protocole et droits ; le domaine autorise la transition ; la base assure atomicité et relations ; l’outbox franchit la frontière externe de façon maîtrisée. Avec ces rôles explicites, l’équipe peut reproduire, corriger et tester le défaut.

Le quiz ci-dessous reprend ces problèmes. Le score est calculé côté serveur. Avec au moins 4/5 et un e-mail confirmé, vous recevrez un code à usage unique pour un premier audit technique payant.

Sources et exercices complémentaires