De l’action à la base de données : voyage interactif entre délégués, événements et architecture
Un atelier interactif du clic dans un composant à l’enregistrement durable : actions, délégués, hiérarchie, relations, EF Core, PostgreSQL, outbox, concurrence et tests E2E.
L’article complet est disponible ci-dessous. Activez JavaScript pour les quiz et les simulations.
Atelier interactif d’architecture
Nous n’apprenons pas les classes indépendamment de la base. Nous construisons un seul parcours, capable de tenir en production.
Dans cette leçon, vous construirez un module de gestion d’ordres de travail : du clic dans l’interface à la règle de domaine, à la transaction, aux relations en base et à l’événement envoyé après l’enregistrement, en passant par une action et un délégué. Vous prendrez six décisions d’architecture, remettrez le pipeline dans le bon ordre et lancerez une simulation de validation.
Cet atelier se lit à trois niveaux. Les débutants verront qui appelle quoi. Les développeurs intermédiaires comprendront où s’arrête la responsabilité de chaque couche. Les profils seniors trouveront des points de contrôle sur la concurrence, l’idempotence, l’observabilité et l’évolution du modèle.
Le projet fil rouge
Système : WorkFlow
Le client crée un ordre de travail. L’opérateur l’accepte. Le système enregistre l’historique, publie un événement et actualise la vue. Tout semble simple, jusqu’au double clic, au ralentissement de la base ou au redémarrage du worker.
Les nouveaux outils aident, mais ne retirent pas sa responsabilité à la conception
Extension members et lambdas simplifiées
Le langage permet d’exprimer plus clairement de petits comportements. Cela n’autorise pas à cacher la logique de domaine dans un ensemble d’extensions disparates.
Un modèle enrichi, des filtres nommés et de nouveaux opérateurs LINQ
LeftJoin, les filtres nommés et l’évolution de la prise en charge de JSON facilitent le travail. Il faut toujours lire le SQL généré, le plan de requête et les effets des migrations.
Une première version plus rapide, la même responsabilité
Un modèle peut proposer une entité, un handler et un test. Il ne connaît pas tous les invariants de l’entreprise, les données de production ni le coût d’une mauvaise décision.
La règle pour 2026 : utilisez les nouveautés lorsqu’elles simplifient le contrat. Ne les ajoutez pas pour rendre un diagramme d’architecture plus moderne.
Comprendre les concepts
Action, commande, événement et délégué ne sont pas quatre noms pour la même chose
L’action de l’utilisateur se produit dans l’interface : un clic, l’envoi d’un formulaire ou le déplacement d’un élément. La commande exprime une intention de changement : « accepter l’ordre de travail 42 ». L’événement décrit un fait déjà survenu : « l’ordre de travail 42 a été accepté ». Le délégué est un contrat typé de comportement que l’on peut transmettre comme une valeur.
La différence essentielle est temporelle. Une commande peut être refusée. On ne refuse pas un événement de domaine : il décrit un fait enregistré par le modèle. Le délégué n’est ni un fait ni une intention. Il dit simplement : « ici, j’ai besoin d’une fonction avec cette entrée et ce résultat ».
Elle a cliqué, envoyé un formulaire ou choisi une option.
Une intention explicite, des données d’entrée et un identifiant de tentative.
Un fait produit après le passage des règles de domaine.
Une fonction typée utilisée sans connaître son implémentation.
Le client a cliqué sur « J’accepte ». L’interface peut-elle afficher immédiatement « enregistré » ?
Oui. Le clic constitue une preuve suffisante.
Non. Il faut d’abord la confirmation du serveur.
Choisissez une réponse. Vous pouvez corriger chaque décision.
La frontière de l’interface
Le composant recueille une intention. Il ne doit pas orchestrer le processus métier
Le composant connaît les champs du formulaire, la disponibilité du bouton, le chargement et le message à afficher. Il ne doit pas savoir que le backend écrit dans trois tables, publie un message et met à jour une projection. Si le navigateur dirige ces étapes, fermer l’onglet au milieu de l’opération laisse le système dans un état intermédiaire.
type AcceptState =
| { kind: 'idle' }
| { kind: 'sending' }
| { kind: 'accepted'; version: number }
| { kind: 'conflict'; message: string }
| { kind: 'failed'; message: string };
async accept(order: WorkOrderView): Promise<void> {
if (this.state().kind === 'sending') return;
this.state.set({ kind: 'sending' });
try {
const result = await firstValueFrom(this.api.accept(order.id, {
expectedVersion: order.version,
idempotencyKey: crypto.randomUUID()
}));
this.state.set({
kind: 'accepted',
version: result.version
});
} catch (error) {
this.state.set(mapAcceptError(error));
}
}
Le composant appelle séparément : enregistrer l’ordre, déduire le quota, envoyer l’e-mail. Que changez-vous ?
Je remplace les trois requêtes par une commande métier.
Je conserve les trois requêtes et lance Promise.all.
Décidez qui doit orchestrer l’opération.
Délégués et événements
Un délégué transporte un comportement. Un événement encadre qui peut le déclencher
Les délégués sont typés et peuvent circuler comme des données. Ils conviennent aux petites stratégies, prédicats, étapes de pipeline et callbacks. Un event utilise un délégué, mais pose une frontière importante : le code externe peut s’abonner ou se désabonner ; seul le propriétaire peut déclencher l’événement.
Pour poser une question à un destinataire et recevoir une réponse unique, utilisez un délégué ou une interface. Pour publier un fait à zéro, un ou plusieurs abonnés, un événement est plus naturel. Si la communication quitte le processus ou doit survivre à un redémarrage, il faut un message durable, un broker et un consommateur idempotent.
public delegate ValueTask<StepResult> WorkOrderStep(
WorkOrderContext context,
CancellationToken cancellationToken);
public sealed class WorkOrderPipeline(IEnumerable<WorkOrderStep> steps)
{
public async ValueTask<StepResult> ExecuteAsync(
WorkOrderContext context,
CancellationToken ct)
{
foreach (var step in steps)
{
var result = await step(context, ct);
if (!result.CanContinue)
return result;
}
return StepResult.Success();
}
}
Le moteur de tarification a besoin d’un petit algorithme de remise dépendant de la campagne. Que choisissez-vous ?
Un délégué typé transmis au calculateur.
Dix classes héritant d’AbstractDiscountBase.
Choisissez le plus petit contrat qui résout vraiment le problème.
La hiérarchie du domaine
La hiérarchie doit exprimer les règles, pas l’organisation des dossiers
Partons du comportement. Un ordre de travail ne peut passer du brouillon à l’acceptation que s’il contient au moins une ligne, un propriétaire valide et une version à jour. L’entité protège la transition. Le handler récupère les données et enregistre le résultat. Le repository ne prend pas la décision métier.
public sealed class WorkOrder
{
private readonly List<IDomainEvent> _events = [];
public WorkOrderId Id { get; private set; }
public WorkOrderStatus Status { get; private set; }
public int Version { get; private set; }
public IReadOnlyList<WorkOrderLine> Lines => _lines;
public Result Accept(UserId actor, DateTimeOffset now)
{
if (_lines.Count == 0)
return Result.Invalid("work_order_has_no_lines");
if (Status is WorkOrderStatus.Accepted)
return Result.NoChange();
if (Status is not WorkOrderStatus.Draft)
return Result.Conflict("invalid_status_transition");
Status = WorkOrderStatus.Accepted;
Version++;
_events.Add(new WorkOrderAccepted(Id, actor, now, Version));
return Result.Changed();
}
}
Où placer la règle « ne pas accepter un ordre sans lignes » ?
Uniquement dans le composant, avec disabled.
Dans le modèle de domaine, avec un reflet dans l’interface.
Choisissez une frontière qu’un autre client API ne peut pas contourner.
La base de données relationnelle
Le modèle en code raconte l’histoire. Le schéma de la base garantit qu’elle peut tenir
Le modèle relationnel ne doit pas reproduire chaque objet à l’identique. Il doit conserver les faits, les relations et les contraintes que le système doit protéger. Un ordre a plusieurs lignes. Chaque tentative de modification possède une clé d’idempotence unique. Chaque message outbox référence l’agrégat qui l’a produit.
C’est la base qui tranche entre deux écritures concurrentes. La validation C# améliore le message, mais l’index unique, la clé étrangère et le jeton de concurrence constituent la dernière protection lorsqu’une nouvelle intégration ou un import arrive.
Id PK
CustomerId FK
Status
Version concurrency
Id PK
WorkOrderId FK
ProductId FK
Quantity CHECK > 0
Id PK
TenantId FK
IdempotencyKey UNIQUE
ResultJson
Id PK
AggregateId
EventType
ProcessedAt NULL
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 });
entity.HasMany(x => x.Lines)
.WithOne()
.HasForeignKey(x => x.WorkOrderId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<CommandReceipt>(entity =>
{
entity.HasIndex(x => new { x.TenantId, x.IdempotencyKey })
.IsUnique();
});
}
Ajoutez une colonne nullable ou dotée d’une valeur par défaut sûre.
Complétez les données par lots et surveillez les verrous et la durée.
Basculez les écritures et les lectures vers le nouveau contrat.
Supprimez l’ancienne colonne seulement après vérification du trafic.
Deux processus ont exécuté AnyAsync et aucun n’a trouvé la clé d’idempotence. Qu’est-ce qui arrêtera le doublon ?
Un autre AnyAsync juste avant SaveChanges.
Un index unique et le traitement du conflit d’écriture.
Choisissez un mécanisme qui tient aussi en présence d’une vraie concurrence.
Transaction et outbox
Le COMMIT confirme l’état. L’outbox transporte le fait au-delà de la base
L’ordre de travail, la preuve d’idempotence et l’entrée outbox doivent être enregistrés dans la même transaction. L’e-mail, le webhook ou la publication au broker restent à l’extérieur : le fournisseur externe ne participe pas à la transaction PostgreSQL. Nous enregistrons donc l’intention de livraison, exécutons le COMMIT, puis un worker livre le message avec reprises et déduplication.
public async Task<AcceptWorkOrderResult> Handle(
AcceptWorkOrder command,
CancellationToken ct)
{
var receipt = await db.CommandReceipts.SingleOrDefaultAsync(
x => x.TenantId == command.TenantId
&& x.IdempotencyKey == command.IdempotencyKey,
ct);
if (receipt is not null)
return receipt.ReadResult<AcceptWorkOrderResult>();
var order = await db.WorkOrders
.Include(x => x.Lines)
.SingleAsync(x => x.Id == command.WorkOrderId, ct);
if (order.Version != command.ExpectedVersion)
throw new WorkOrderConflictException();
order.Accept(command.ActorId, clock.UtcNow);
var result = AcceptWorkOrderResult.From(order);
db.CommandReceipts.Add(CommandReceipt.From(command, result));
db.OutboxMessages.AddRange(order.DequeueEvents().Select(OutboxMessage.From));
await db.SaveChangesAsync(ct);
return result;
}
Deux opérateurs lisent le même enregistrement.
La base passe la version à 8.
La condition de mise à jour ne correspond plus.
L’interface actualise l’état au lieu d’écraser la modification.
Quand peut-on envoyer le message « ordre de travail accepté » ?
Après le COMMIT, par un worker qui lit la transactional outbox.
Avant SaveChanges, pour aller plus vite.
Choisissez le moment qui ne crée pas de faux faits.
Observabilité et tests
Le système est prêt lorsque vous pouvez reconstituer sa décision
Un log « quelque chose a échoué » ne suffit pas. Chaque opération doit avoir un identifiant de corrélation, le nom du cas d’usage, un identifiant anonyme de l’acteur, la version de l’agrégat et la durée de chaque étape. Ne conservez pas les charges utiles complètes, les tokens ou les données sensibles. La télémétrie sert à reconstituer le parcours, pas à créer une seconde base clients dans les logs.
using var activity = telemetry.StartActivity("work_order.accept");
activity?.SetTag("work_order.id", command.WorkOrderId);
activity?.SetTag("work_order.expected_version", command.ExpectedVersion);
activity?.SetTag("operation.idempotency_hash",
hasher.ShortHash(command.IdempotencyKey));
var result = await handler.Handle(command, ct);
metrics.WorkOrdersAccepted.Add(1,
new("result", result.Kind),
new("source", command.Source));
Vérifie rapidement les transitions d’état et les invariants.
Exécute EF Core avec le véritable fournisseur et vérifie les index et la transaction.
Confirme les statuts 200, 400, 403, 409 et un Problem Details stable.
Va de l’interface à l’effet durable et à sa trace télémétrique.
Jeu : construisez le parcours
Choisissez les tuiles dans l’ordre suivi par une opération sûre à travers le système
Règle de domainel’agrégat prend la décision
L’action de l’utilisateurl’intention apparaît
Worker outboxlivre l’effet après le COMMIT
Handler applicatifcoordonne le cas d’usage
Nouvel état de l’interfaceaffiche le résultat confirmé
Contrat HTTPautorise l’action et valide l’entrée
Transaction en baseétat, preuve de traitement et outbox ensemble
Commencez là où l’utilisateur exprime son intention.
Recommencer
Simulateur de déploiement
Faites valider votre modification pour la production
La simulation vérifie le contrat, l’autorisation, le conflit de version, l’unicité de la clé, l’outbox atomique, les reprises du worker et l’actualisation de la vue.
Lancer le test complet
L’environnement est prêt. Lancez le parcours contrôlé.
Avant le merge, posez les questions invisibles dans le scénario idéal
Qui est responsable de la décision ?
La même règle s’applique-t-elle à l’API, au worker et à l’import ?
Que se passe-t-il lors d’une répétition ?
L’opération renvoie-t-elle le même résultat ou produit-elle un second effet ?
Que feront deux processus simultanés ?
La base tranchera-t-elle atomiquement le conflit et l’interface expliquera-t-elle le 409 ?
Qu’est-ce qui peut tomber entre deux étapes ?
La transaction et l’outbox permettront-elles de reprendre sans nettoyage manuel ?
Comment changerons-nous le modèle sans interruption ?
La migration accepte-t-elle deux versions de l’application pendant le rollout ?
Comment démontrerons-nous le résultat ?
Les tests et la télémétrie montreront-ils l’effet durable sans exposer de données ?
Choisissez l’outil selon le problème
| Besoin | Mécanisme minimal pertinent | Raccourci dangereux | Preuve de réception |
|---|---|---|---|
| Transmettre un petit comportement | délégué / Func / stratégie | hiérarchie sans état ni règles | test des entrées et du résultat de la fonction |
| Notifier plusieurs abonnés dans un processus | event avec un propriétaire explicite | event bus global statique | test des abonnements et de l’absence de fuites |
| Transporter un fait hors du processus | outbox + broker + consommateur idempotent | publication avant le COMMIT | test des reprises et de la déduplication |
| Protéger une relation entre données | FK, UNIQUE, CHECK, jeton de concurrence | validation uniquement dans l’interface | test d’intégration avec le fournisseur |
| Observer tout le parcours | trace + métriques + logs sûrs | charge utile complète dans chaque log | identifiant de corrélation de l’interface au worker |
À emporter dans votre projet
Une bonne architecture se juge à ses décisions explicites et à ses résultats vérifiables, pas au nombre de couches.
L’action ouvre la conversation. La commande porte l’intention. Le handler orchestre le cas d’usage. Le domaine répond oui ou non. La base assure la cohérence. L’outbox franchit la frontière du système sans risque. L’interface n’affiche le résultat que lorsqu’elle en possède la preuve.
Si vous débutez, revenez aux concepts et reconstituez le parcours. À un niveau intermédiaire, essayez d’écrire les contrats de votre propre cas d’usage. Si vous êtes responsable du système en tant que senior, passez en revue avec l’équipe les reprises, la concurrence, les migrations et les traces télémétriques.
Le quiz final ci-dessous est évalué côté serveur. Il ne teste pas votre mémoire des définitions. Il vérifie votre capacité à choisir une solution qui restera fidèle à la réalité lorsque la production cessera de ressembler au scénario idéal local.