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.

Objectif : un enregistrement cohérent et un résultat clair
Risques : doublons, mise à jour perdue, fausse réussite
Stack : Angular, C# 14, ASP.NET Core, EF Core 10, PostgreSQL
Validation : tests de contrat, d’intégration et E2E
Les nouveautés utiles

Les nouveaux outils aident, mais ne retirent pas sa responsabilité à la conception

C# 14

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.

EF Core 10 LTS

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.

L’IA dans l’équipe

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.

Niveau 01

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 ».

UI action Qu’a fait la personne ?

Elle a cliqué, envoyé un formulaire ou choisi une option.

Command Que doit tenter le système ?

Une intention explicite, des données d’entrée et un identifiant de tentative.

Domain event Que s’est-il déjà passé ?

Un fait produit après le passage des règles de domaine.

Delegate Quel comportement injectons-nous ?

Une fonction typée utilisée sans connaître son implémentation.

Décision 1/6

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.

Niveau 02

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));
  }
}
Décision 2/6

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.

Niveau 03

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();
    }
}
Pipeline Appelle le contrat
01Validationl’entrée est-elle complète ?
02Autorisationl’acteur peut-il modifier l’état ?
03Règlele domaine autorise-t-il cette transition ?
Décision 3/6

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.

Niveau 04

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.

Module WorkFlow Frontière métier
Application AcceptWorkOrderHandler coordonne le cas d’usage
Domaine WorkOrder aggregate protège les invariants
Infrastructure EF Core + Outbox assure la persistance
Command Result Entity Value Object DbContext Worker
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();
    }
}
Décision 4/6

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.

Niveau 05

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.

WorkOrders Id PK CustomerId FK Status Version concurrency
WorkOrderLines Id PK WorkOrderId FK ProductId FK Quantity CHECK > 0
CommandReceipts Id PK TenantId FK IdempotencyKey UNIQUE ResultJson
OutboxMessages 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();
    });
}
Une migration sûre Expand

Ajoutez une colonne nullable ou dotée d’une valeur par défaut sûre.

Backfill Migrate data

Complétez les données par lots et surveillez les verrous et la durée.

Switch Deploy code

Basculez les écritures et les lectures vers le nouveau contrat.

Contract Remove old

Supprimez l’ancienne colonne seulement après vérification du trafic.

Décision 5/6

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.

Niveau 06

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

Deux opérateurs lisent le même enregistrement.

T1L’opérateur B enregistre

La base passe la version à 8.

T2L’opérateur A envoie 7

La condition de mise à jour ne correspond plus.

T3L’API renvoie 409

L’interface actualise l’état au lieu d’écraser la modification.

Décision 6/6

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.

Niveau 07

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));
01 Test du domaine

Vérifie rapidement les transitions d’état et les invariants.

02 Test d’intégration

Exécute EF Core avec le véritable fournisseur et vérifie les index et la transaction.

03 Test de contrat

Confirme les statuts 200, 400, 403, 409 et un Problem Details stable.

04 Test E2E

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

Progression 0/7

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

UIAPIDomaineDBOutboxE2E

L’environnement est prêt. Lancez le parcours contrôlé.

Le regard d’un senior

Avant le merge, posez les questions invisibles dans le scénario idéal

Frontière

Qui est responsable de la décision ?

La même règle s’applique-t-elle à l’API, au worker et à l’import ?

Reprise

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 ?

Concurrence

Que feront deux processus simultanés ?

La base tranchera-t-elle atomiquement le conflit et l’interface expliquera-t-elle le 409 ?

Défaillance

Qu’est-ce qui peut tomber entre deux étapes ?

La transaction et l’outbox permettront-elles de reprendre sans nettoyage manuel ?

Évolution

Comment changerons-nous le modèle sans interruption ?

La migration accepte-t-elle deux versions de l’application pendant le rollout ?

Preuve

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 ?

Aide-mémoire

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.

Sources et approfondissements