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é
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.
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");
requestId absent, version incorrecte ou données hors contrat.
L’utilisateur est connecté mais ne peut pas lancer cette mission.
L’enregistrement a changé depuis la lecture ou cette intention a déjà été traitée.
La réponse contient la nouvelle version et le statut confirmé par la base.
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.
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.
Id PK
TenantId FK
Status
Version
Id PK
WorkOrderId FK
RequestId UNIQUE
ResultJson
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.
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.
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.
Les deux interfaces montrent le même état.
La base passe à la version 8.
UPDATE ne trouve aucune version correspondante.
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.");
}
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
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
L’attente et l’erreur sont-elles affichées sans prétendre à un succès ?
La requête porte-t-elle requestId et version lorsque nécessaire ?
La règle fonctionne-t-elle quel que soit l’appelant ?
Clés étrangères, index et jeton de concurrence protègent-ils les données en course ?
E-mail, webhook et file sont-ils déclenchés après une écriture confirmée ?
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.