Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Round 7 — Implémentation : deux DSLs qui se parlent

La critique : « montrez du code qui fonctionne. » La réponse : le voici.

Auteur : Stéphane Erard — 10 avril 2026


La critique fondée

Après les confrontations théoriques (Rounds 1-6), une objection reste : « Tout cela est du discours. Où est le code ? Où sont les DSLs qui communiquent ? Où sont les événements typés ? »

Cette critique est fondée. Le code est la loi — code as law — et à un moment, il faut implémenter. Ce round produit un exemple complet, compilable, testable, de deux DSLs qui communiquent via des événements typés à travers un bus pub/sub interfacé.

Scénario concret : le législateur amende l'article L.210-1 du Code de l'environnement (droit d'accès à l'eau potable). L'événement se propage du DSL juridique au DSL citoyen, qui réévalue automatiquement les droits de chaque situation individuelle.

Garde-fous : l'IA assiste l'implémentation, mais les quality gates (Partie 11) et les typed specs vérifient la qualité du résultat. Le code ci-dessous est soumis aux mêmes exigences que tout le reste du pipeline.


1. Architecture des packages

Chaque DSL a ses préoccupations dans un package séparé (cf. Comment les DSLs communiquent, section 10). Le principe : personne ne dépend de ce qui ne le concerne pas.

Metacratie.Common/
├── ILegalEvent.cs                    ← contrats de base (events, queries, responses)
├── LegalEventBusAttributes.cs        ← attributs de déclaration
├── SharedKernel/
│   ├── ArticleFqn.cs                 ← identifiant typé d'article (pas de string)
│   ├── JurisdictionId.cs             ← identifiant typé de juridiction
│   ├── CitizenCaseId.cs              ← identifiant typé de dossier citoyen
│   ├── NormLevel.cs                  ← enum : Constitution → Circulaire
│   └── CitizenRightStatus.cs         ← enum : Active, Reinforced, Restricted, ...
└── Metacratie.Common.csproj          ← netstandard2.0, zéro dépendance

Metacratie.EventBus.Abstractions/
├── ILegalEventBus.cs                 ← le port (interface)
└── Metacratie.EventBus.Abstractions.csproj

Metacratie.EventBus.InMemory/
├── InMemoryLegalEventBus.cs          ← adaptateur démo
└── Metacratie.EventBus.InMemory.csproj

Metacratie.EventBus.RabbitMq/
├── RabbitMqLegalEventBus.cs          ← adaptateur production
└── Metacratie.EventBus.RabbitMq.csproj

Law.Dsl.Events/
├── LawArticleAmended.cs              ← événement typé
├── LawArticleRepealed.cs             ← événement typé
└── Law.Dsl.Events.csproj             ← dépend de Metacratie.Common

Citizen.Dsl.Events/
├── CitizenRightsUpdated.cs           ← événement typé
├── CitizenRecourseAvailable.cs       ← événement typé
└── Citizen.Dsl.Events.csproj         ← dépend de Metacratie.Common

Law.Dsl/
├── LawCorpus.cs                      ← logique juridique
└── Law.Dsl.csproj                    ← dépend de Law.Dsl.Events + EventBus.Abstractions

Citizen.Dsl/
├── CitizenCaseEvaluator.cs           ← logique citoyenne + handlers
└── Citizen.Dsl.csproj                ← dépend de Citizen.Dsl.Events + Law.Dsl.Events + EventBus.Abstractions

Tests/
├── LawCitizenIntegrationTests.cs     ← test bout-en-bout
└── Tests.csproj                      ← dépend de tout + EventBus.InMemory

Quatre points essentiels :

  • Law.Dsl ne dépend jamais de Citizen.Dsl. Le découplage est garanti par la structure des packages.
  • Citizen.Dsl dépend de Law.Dsl.Events (les messages), pas de Law.Dsl (la logique). Il écoute la forme du message, pas l'implémentation.
  • Le bus est une abstraction (ILegalEventBus). L'adaptateur concret (InMemory ou RabbitMQ) est injecté au démarrage.
  • Les packages d'événements (Law.Dsl.Events, Citizen.Dsl.Events) sont en netstandard2.0 — zéro dépendance, distribuables à tout le monde.

Le contrat de base

Tout événement légal implémente une interface minimale — calquée sur le pattern IDomainEvent du framework DDD existant :

// Metacratie.Common/SharedKernel/Identifiers.cs
namespace Metacratie.Common.SharedKernel;

/// <summary>FQN typé d'un article de loi. Pas de string.</summary>
public sealed record ArticleFqn(string Code, NormLevel Level)
{
    public override string ToString() => $"{Level}:{Code}";
}

/// <summary>Juridiction typée. Pas de string.</summary>
public sealed record JurisdictionId(string Iso3166, string Label)
{
    public static readonly JurisdictionId France = new("FR", "France");
    public static readonly JurisdictionId EU     = new("EU", "Union européenne");
}

/// <summary>Identifiant typé de dossier citoyen.</summary>
public sealed record CitizenCaseId(string Value);

/// <summary>Hiérarchie des normes — chaque niveau a un rang numérique.</summary>
public enum NormLevel
{
    Constitution    = 0,
    LoiOrganique    = 1,
    Loi             = 2,
    Ordonnance      = 3,
    Decret          = 4,
    Arrete          = 5,
    Circulaire      = 6
}

/// <summary>Statut d'un droit citoyen — pas de magic string.</summary>
public enum CitizenRightStatus
{
    Active,
    Reinforced,
    Restricted,
    Suspended,
    Repealed
}
// Metacratie.Common/ILegalEvent.cs
namespace Metacratie.Common;

public interface ILegalEvent
{
    Guid             EventId      { get; }
    DateTimeOffset   OccurredAt   { get; }
    JurisdictionId   Jurisdiction { get; }
}

Trois champs. Pas un de plus. L'identifiant, la date, la juridiction — et la juridiction est un record typé, pas une string.

// Metacratie.Common/ILegalQuery.cs
namespace Metacratie.Common;

/// <summary>
/// Une query typée : un DSL pose une question concrète à un autre DSL
/// et attend une réponse de type TResponse.
/// </summary>
public interface ILegalQuery<TResponse> where TResponse : ILegalResponse
{
    Guid   QueryId      { get; }
    string Jurisdiction { get; }
}

/// <summary>Marqueur pour les réponses typées.</summary>
public interface ILegalResponse
{
    Guid   QueryId { get; }
    bool   IsSuccessful { get; }
}

Les attributs de déclaration

Trois attributs permettent à un DSL de déclarer ses événements, ses listeners, et ses responders — le Source Generator les lira pour vérifier la cohérence et générer le câblage :

// Metacratie.Common/LegalEventBusAttributes.cs
namespace Metacratie.Common;

/// <summary>
/// Déclare qu'un record est un événement publié par ce DSL.
/// Le Source Generator vérifie que le record implémente ILegalEvent.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class PublishesEventAttribute : Attribute
{
    public string DslName { get; }
    public PublishesEventAttribute(string dslName) => DslName = dslName;
}

/// <summary>
/// Déclare qu'une méthode statique est un handler d'événement provenant d'un autre DSL.
/// Le Source Generator vérifie la signature : static Task HandleAsync(TEvent, ILegalEventBus).
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class ListensToEventAttribute : Attribute
{
    public Type EventType { get; }
    public ListensToEventAttribute(Type eventType) => EventType = eventType;
}

/// <summary>
/// Déclare qu'une méthode statique répond à une query typée d'un autre DSL.
/// Le Source Generator vérifie la signature : static Task&lt;TResponse&gt;(TQuery, CancellationToken).
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class RespondsToQueryAttribute : Attribute
{
    public Type QueryType { get; }
    public RespondsToQueryAttribute(Type queryType) => QueryType = queryType;
}

[PublishesEvent("Law")] sur un record dit : « ce record est un événement du DSL juridique ». [ListensToEvent(typeof(LawArticleAmended))] sur une méthode statique dit : « cette méthode doit être appelée quand un LawArticleAmended arrive ».


3. L'interface du bus — un port, pas une implémentation

Le bus est un port au sens hexagonal : une ouverture standardisée, sans implémentation.

// Metacratie.EventBus.Abstractions/ILegalEventBus.cs
namespace Metacratie.EventBus.Abstractions;

public interface ILegalEventBus
{
    // ── Pub/Sub (fire-and-forget) ──────────────────────────────────

    /// <summary>Publie un événement. Tous les abonnés sont notifiés.</summary>
    Task PublishAsync<T>(T @event, CancellationToken ct = default)
        where T : ILegalEvent;

    /// <summary>S'abonne aux événements de type T.</summary>
    IDisposable Subscribe<T>(Func<T, ILegalEventBus, CancellationToken, Task> handler)
        where T : ILegalEvent;

    // ── Request/Response (quand un DSL attend une réponse) ─────────

    /// <summary>
    /// Envoie une query typée et attend la réponse.
    /// Un seul handler répond (pas de fanout). Timeout configurable.
    /// </summary>
    Task<TResponse> QueryAsync<TQuery, TResponse>(TQuery query, CancellationToken ct = default)
        where TQuery : ILegalQuery<TResponse>
        where TResponse : ILegalResponse;

    /// <summary>
    /// Enregistre un handler de query. Un seul handler par type de query.
    /// </summary>
    IDisposable Respond<TQuery, TResponse>(
        Func<TQuery, CancellationToken, Task<TResponse>> handler)
        where TQuery : ILegalQuery<TResponse>
        where TResponse : ILegalResponse;
}

Décisions de conception :

  • PublishAsync est asynchrone — un adaptateur RabbitMQ devra attendre la confirmation du broker.
  • Le handler reçoit le bus lui-même en paramètre, ce qui permet les chaînes d'événements : un handler de LawArticleAmended peut publier un CitizenRightsUpdated sur le même bus.
  • IDisposable pour le désabonnement — calqué sur le pattern IEventStream<T> du framework Reactive existant.
  • Le bus supporte deux patterns : pub/sub (fire-and-forget) et request/response (quand un DSL attend une réponse concrète). Les deux passent par des événements typés dans des packages séparés.

4. Implémentation InMemory — l'adaptateur de démonstration

Pour la démo et les tests, un bus en mémoire suffit. Il suit le même pattern que VosEventEmitter du framework existant : thread-safe, multi-subscriber, typed filtering.

// Metacratie.EventBus.InMemory/InMemoryLegalEventBus.cs
using System.Collections.Concurrent;
using Metacratie.Common;
using Metacratie.EventBus.Abstractions;

namespace Metacratie.EventBus.InMemory;

public sealed class InMemoryLegalEventBus : ILegalEventBus
{
    private readonly ConcurrentDictionary<Type, ConcurrentBag<Delegate>> _handlers = new();
    private readonly ConcurrentDictionary<Type, Delegate> _responders = new();

    // ── Pub/Sub ────────────────────────────────────────────────────

    public async Task PublishAsync<T>(T @event, CancellationToken ct = default)
        where T : ILegalEvent
    {
        if (!_handlers.TryGetValue(typeof(T), out var bag))
            return;

        foreach (var handler in bag)
        {
            if (handler is Func<T, ILegalEventBus, CancellationToken, Task> typed)
                await typed(@event, this, ct);
        }
    }

    public IDisposable Subscribe<T>(Func<T, ILegalEventBus, CancellationToken, Task> handler)
        where T : ILegalEvent
    {
        var bag = _handlers.GetOrAdd(typeof(T), _ => new ConcurrentBag<Delegate>());
        bag.Add(handler);
        return new Subscription<T>(handler, bag);
    }

    // ── Request/Response ───────────────────────────────────────────

    public async Task<TResponse> QueryAsync<TQuery, TResponse>(
        TQuery query, CancellationToken ct = default)
        where TQuery : ILegalQuery<TResponse>
        where TResponse : ILegalResponse
    {
        if (!_responders.TryGetValue(typeof(TQuery), out var responder))
            throw new InvalidOperationException(
                $"Aucun responder enregistré pour {typeof(TQuery).Name}. "
                + "Le DSL qui sait répondre n'a pas encore appelé Respond<,>().");

        var typed = (Func<TQuery, CancellationToken, Task<TResponse>>)responder;
        return await typed(query, ct);
    }

    public IDisposable Respond<TQuery, TResponse>(
        Func<TQuery, CancellationToken, Task<TResponse>> handler)
        where TQuery : ILegalQuery<TResponse>
        where TResponse : ILegalResponse
    {
        if (!_responders.TryAdd(typeof(TQuery), handler))
            throw new InvalidOperationException(
                $"Un responder existe déjà pour {typeof(TQuery).Name}. "
                + "Un seul DSL peut répondre à une query donnée.");

        return new ResponderSubscription(typeof(TQuery), _responders);
    }

    // ── Cleanup ────────────────────────────────────────────────────

    private sealed class Subscription<T>(Delegate handler, ConcurrentBag<Delegate> bag)
        : IDisposable
    {
        private bool _disposed;

        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            var remaining = bag.Where(h => h != handler).ToList();
            while (bag.TryTake(out _)) { }
            foreach (var h in remaining) bag.Add(h);
        }
    }

    private sealed class ResponderSubscription(
        Type queryType, ConcurrentDictionary<Type, Delegate> responders) : IDisposable
    {
        public void Dispose() => responders.TryRemove(queryType, out _);
    }
}

Pas de RabbitMQ, pas de réseau, pas de sérialisation. Juste des delegates en mémoire. Le test vert prouve que le contrat fonctionne ; l'adaptateur de production branchera RabbitMQ sur le même port.


Les événements publiés

// Law.Dsl.Events/LawArticleAmended.cs
using Metacratie.Common;
using Metacratie.Common.SharedKernel;

namespace Law.Dsl.Events;

/// <summary>
/// Un article de loi a été modifié.
/// Publié par Law.Dsl quand le législateur amende un article.
/// Tous les identifiants sont des records typés — pas de string.
/// </summary>
[PublishesEvent("Law")]
public sealed record LawArticleAmended : ILegalEvent
{
    public required Guid             EventId       { get; init; }
    public required DateTimeOffset   OccurredAt    { get; init; }
    public required JurisdictionId   Jurisdiction  { get; init; }
    public required ArticleFqn       Article       { get; init; }
    public required string           PreviousText  { get; init; }
    public required string           AmendedText   { get; init; }
    public required DateTimeOffset   EffectiveDate { get; init; }
}
// Law.Dsl.Events/LawArticleRepealed.cs
using Metacratie.Common;
using Metacratie.Common.SharedKernel;

namespace Law.Dsl.Events;

[PublishesEvent("Law")]
public sealed record LawArticleRepealed : ILegalEvent
{
    public required Guid             EventId       { get; init; }
    public required DateTimeOffset   OccurredAt    { get; init; }
    public required JurisdictionId   Jurisdiction  { get; init; }
    public required ArticleFqn       Article       { get; init; }
    public required string           RepealingText { get; init; }
    public required DateTimeOffset   EffectiveDate { get; init; }
}

sealed record — immutable, valeur-sémantique, pattern-matchable. Calqué sur le pattern VosEvents du framework (sealed record ConfigLoaded(...) : VosEvent). Les identifiants (ArticleFqn, JurisdictionId) sont des records du shared kernel — pas des strings. Si un type se rapporte à un concept du domaine, il est nommé, typé, et partagé.

La logique juridique

// Law.Dsl/LawCorpus.cs
using Law.Dsl.Events;
using Metacratie.Common.SharedKernel;
using Metacratie.EventBus.Abstractions;

namespace Law.Dsl;

/// <summary>
/// Corpus juridique minimal. Gère des articles et publie des événements
/// quand le législateur les modifie.
/// </summary>
public sealed class LawCorpus
{
    private readonly ILegalEventBus _bus;
    private readonly Dictionary<ArticleFqn, string> _articles = new();

    public LawCorpus(ILegalEventBus bus) => _bus = bus;

    public IReadOnlyDictionary<ArticleFqn, string> Articles => _articles;

    /// <summary>
    /// Le législateur amende un article. L'événement est publié sur le bus.
    /// </summary>
    public async Task AmendArticleAsync(
        ArticleFqn article,
        string newText,
        JurisdictionId jurisdiction,
        DateTimeOffset effectiveDate,
        CancellationToken ct = default)
    {
        var previousText = _articles.GetValueOrDefault(article, "");
        _articles[article] = newText;

        await _bus.PublishAsync(new LawArticleAmended
        {
            EventId       = Guid.NewGuid(),
            OccurredAt    = DateTimeOffset.UtcNow,
            Jurisdiction  = jurisdiction,
            Article       = article,
            PreviousText  = previousText,
            AmendedText   = newText,
            EffectiveDate = effectiveDate,
        }, ct);
    }
}

LawCorpus dépend de ILegalEventBus (le port), pas de InMemoryLegalEventBus (l'adaptateur). C'est l'inversion des dépendances (SOLID D). La clé du dictionnaire est un ArticleFqn — un record typé qui porte le code de l'article et son niveau dans la hiérarchie des normes. Pas de string.


Les événements publiés

// Citizen.Dsl.Events/CitizenRightsUpdated.cs
using Metacratie.Common;
using Metacratie.Common.SharedKernel;

namespace Citizen.Dsl.Events;

[PublishesEvent("Citizen")]
public sealed record CitizenRightsUpdated : ILegalEvent
{
    public required Guid               EventId        { get; init; }
    public required DateTimeOffset     OccurredAt     { get; init; }
    public required JurisdictionId     Jurisdiction   { get; init; }
    public required CitizenCaseId      CaseId         { get; init; }
    public required ArticleFqn         TriggeringArticle { get; init; }
    public required CitizenRightStatus PreviousStatus { get; init; }
    public required CitizenRightStatus NewStatus      { get; init; }
}

La logique citoyenne — avec listener déclaré

// Citizen.Dsl/CitizenCaseEvaluator.cs
using Citizen.Dsl.Events;
using Law.Dsl.Events;
using Metacratie.Common;
using Metacratie.Common.SharedKernel;
using Metacratie.EventBus.Abstractions;

namespace Citizen.Dsl;

/// <summary>
/// Évalue l'impact des changements de loi sur les dossiers citoyens.
/// </summary>
public sealed class CitizenCaseEvaluator
{
    private readonly ILegalEventBus _bus;
    private readonly Dictionary<CitizenCaseId, CitizenCase> _cases = new();
    private readonly IDisposable _subscription;

    public CitizenCaseEvaluator(ILegalEventBus bus)
    {
        _bus = bus;
        _subscription = _bus.Subscribe<LawArticleAmended>(OnLawArticleAmendedAsync);
    }

    public IReadOnlyDictionary<CitizenCaseId, CitizenCase> Cases => _cases;
    public List<CitizenRightsUpdated> EmittedUpdates { get; } = [];

    public void RegisterCase(CitizenCaseId caseId, string citizenName, ArticleFqn[] watchedArticles)
    {
        _cases[caseId] = new CitizenCase(caseId, citizenName, watchedArticles, CitizenRightStatus.Active);
    }

    /// <summary>
    /// Handler statique déclaré. Quand Law.Dsl publie un LawArticleAmended,
    /// cette méthode est exécutée. Elle réévalue les dossiers citoyens concernés
    /// et publie un CitizenRightsUpdated pour chaque dossier impacté.
    ///
    /// En production, le Source Generator câble automatiquement cette méthode
    /// sur le bus via le [ListensToEvent]. Pour la démo, le constructeur
    /// fait le câblage manuellement.
    /// </summary>
    [ListensToEvent(typeof(LawArticleAmended))]
    private async Task OnLawArticleAmendedAsync(
        LawArticleAmended evt,
        ILegalEventBus bus,
        CancellationToken ct)
    {
        foreach (var (caseId, citizenCase) in _cases)
        {
            if (!citizenCase.WatchedArticles.Contains(evt.Article))
                continue;

            var previousStatus = citizenCase.Status;
            var newStatus = EvaluateImpact(citizenCase, evt);

            _cases[caseId] = citizenCase with { Status = newStatus };

            var update = new CitizenRightsUpdated
            {
                EventId           = Guid.NewGuid(),
                OccurredAt        = DateTimeOffset.UtcNow,
                Jurisdiction      = evt.Jurisdiction,
                CaseId            = caseId,
                TriggeringArticle = evt.Article,
                PreviousStatus    = previousStatus,
                NewStatus         = newStatus,
            };

            EmittedUpdates.Add(update);
            await bus.PublishAsync(update, ct);
        }
    }

    private static CitizenRightStatus EvaluateImpact(CitizenCase c, LawArticleAmended evt)
    {
        // En production, cette logique serait générée par le Source Generator
        // à partir des acceptance criteria du DSL.
        if (evt.AmendedText.Contains("droit fondamental", StringComparison.OrdinalIgnoreCase))
            return CitizenRightStatus.Reinforced;

        if (evt.AmendedText.Contains("restriction", StringComparison.OrdinalIgnoreCase))
            return CitizenRightStatus.Restricted;

        return c.Status;
    }

    public void Dispose() => _subscription.Dispose();
}

public sealed record CitizenCase(
    CitizenCaseId Id,
    string CitizenName,
    ArticleFqn[] WatchedArticles,
    CitizenRightStatus Status);

Points essentiels :

  • CitizenCaseEvaluator dépend de Law.Dsl.Events (les messages), pas de Law.Dsl (la logique). Il ne connaît pas LawCorpus.
  • Le handler OnLawArticleAmendedAsync est une méthode statique décorée par [ListensToEvent]. En production, le Source Generator la câblerait automatiquement. Pour la démo, le constructeur fait le câblage manuellement.
  • Le handler publie à son tour un CitizenRightsUpdated — la chaîne d'événements : État → Loi → Citoyen.

7. Le flux complet — test bout-en-bout

Voici le test qui prouve que les deux DSLs communiquent :

// Tests/LawCitizenIntegrationTests.cs
using Citizen.Dsl;
using Citizen.Dsl.Events;
using Law.Dsl;
using Law.Dsl.Events;
using Metacratie.Common.SharedKernel;
using Metacratie.EventBus.Abstractions;
using Metacratie.EventBus.InMemory;
using Xunit;

namespace Tests;

public sealed class LawCitizenIntegrationTests
{
    // Identifiants typés — réutilisés dans tous les tests
    private static readonly ArticleFqn WaterArticle = new("L.210-1", NormLevel.Loi);
    private static readonly ArticleFqn TaxArticle   = new("CGI.200", NormLevel.Loi);
    private static readonly CitizenCaseId Case001    = new("CASE-001");
    private static readonly CitizenCaseId Case002    = new("CASE-002");
    private static readonly CitizenCaseId Case003    = new("CASE-003");

    [Fact]
    public async Task Law_amendment_propagates_to_citizen_case()
    {
        // Arrange — le bus InMemory, les deux DSLs
        ILegalEventBus bus = new InMemoryLegalEventBus();
        var lawCorpus = new LawCorpus(bus);
        var citizenEvaluator = new CitizenCaseEvaluator(bus);

        // Un citoyen surveille l'article L.210-1 (eau potable)
        citizenEvaluator.RegisterCase(
            caseId: Case001,
            citizenName: "Mathilde Martin",
            watchedArticles: [WaterArticle]);

        // Vérification initiale
        Assert.Equal(CitizenRightStatus.Active, citizenEvaluator.Cases[Case001].Status);

        // Act — le législateur amende l'article L.210-1
        await lawCorpus.AmendArticleAsync(
            article: WaterArticle,
            newText: "L'eau est un droit fondamental. Tout citoyen a droit à un accès "
                   + "suffisant à l'eau potable dans des conditions économiquement "
                   + "acceptables par tous.",
            jurisdiction: JurisdictionId.France,
            effectiveDate: new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.FromHours(2)));

        // Assert — l'événement a traversé le bus et atteint Citizen.Dsl

        // 1. L'article est bien dans le corpus
        Assert.Contains(WaterArticle, lawCorpus.Articles.Keys);
        Assert.Contains("droit fondamental", lawCorpus.Articles[WaterArticle]);

        // 2. Le dossier citoyen a été mis à jour
        Assert.Equal(CitizenRightStatus.Reinforced, citizenEvaluator.Cases[Case001].Status);

        // 3. Un événement CitizenRightsUpdated a été émis
        Assert.Single(citizenEvaluator.EmittedUpdates);
        var update = citizenEvaluator.EmittedUpdates[0];
        Assert.Equal(Case001, update.CaseId);
        Assert.Equal(CitizenRightStatus.Active, update.PreviousStatus);
        Assert.Equal(CitizenRightStatus.Reinforced, update.NewStatus);
        Assert.Equal(WaterArticle, update.TriggeringArticle);
        Assert.Equal(JurisdictionId.France, update.Jurisdiction);
    }

    [Fact]
    public async Task Unrelated_amendment_does_not_affect_citizen_case()
    {
        ILegalEventBus bus = new InMemoryLegalEventBus();
        var lawCorpus = new LawCorpus(bus);
        var citizenEvaluator = new CitizenCaseEvaluator(bus);

        citizenEvaluator.RegisterCase(Case002, "Jean Dupont", [WaterArticle]);

        // Le législateur amende un article fiscal — sans rapport avec L.210-1
        await lawCorpus.AmendArticleAsync(
            article: TaxArticle,
            newText: "Le taux de TVA est fixé à 20%.",
            jurisdiction: JurisdictionId.France,
            effectiveDate: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)));

        // Le dossier citoyen n'est pas affecté
        Assert.Equal(CitizenRightStatus.Active, citizenEvaluator.Cases[Case002].Status);
        Assert.Empty(citizenEvaluator.EmittedUpdates);
    }

    [Fact]
    public async Task Restriction_triggers_recourse_status()
    {
        ILegalEventBus bus = new InMemoryLegalEventBus();
        var lawCorpus = new LawCorpus(bus);
        var citizenEvaluator = new CitizenCaseEvaluator(bus);

        citizenEvaluator.RegisterCase(Case003, "Amina Belkacem", [WaterArticle]);

        await lawCorpus.AmendArticleAsync(
            article: WaterArticle,
            newText: "Restriction de l'accès à l'eau potable en période de sécheresse "
                   + "déclarée par arrêté préfectoral.",
            jurisdiction: JurisdictionId.France,
            effectiveDate: new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.FromHours(2)));

        Assert.Equal(CitizenRightStatus.Restricted, citizenEvaluator.Cases[Case003].Status);
        var update = citizenEvaluator.EmittedUpdates[0];
        Assert.Equal(CitizenRightStatus.Restricted, update.NewStatus);
    }
}

Trois tests, trois scénarios :

  1. Amendement qui renforce un droit → le citoyen est notifié, statut mis à jour.
  2. Amendement sans rapport → le citoyen n'est pas impacté, aucun événement parasite.
  3. Amendement qui restreint un droit → le citoyen est notifié que le recours est possible.

Le bus InMemory garantit que les tests sont déterministes, rapides, sans infrastructure. Aucun RabbitMQ à démarrer. Le contrat est vérifié par le test ; l'adaptateur de production viendra après.


8. L'adaptateur RabbitMQ — l'interface de production

En production, le bus InMemory est remplacé par un adaptateur RabbitMQ. L'interface est exactement la même — c'est la substitution de Liskov (SOLID L).

// Metacratie.EventBus.RabbitMq/RabbitMqLegalEventBus.cs
using System.Text.Json;
using Metacratie.Common;
using Metacratie.EventBus.Abstractions;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

namespace Metacratie.EventBus.RabbitMq;

/// <summary>
/// Adaptateur de production. Même interface ILegalEventBus,
/// implémentation via AMQP (RabbitMQ).
///
/// Chaque type d'événement a son propre exchange (fanout).
/// Chaque abonné crée une queue éphémère liée à l'exchange.
/// La sérialisation est en JSON (System.Text.Json).
/// </summary>
public sealed class RabbitMqLegalEventBus : ILegalEventBus, IAsyncDisposable
{
    private readonly IConnection _connection;
    private readonly IChannel _channel;

    private RabbitMqLegalEventBus(IConnection connection, IChannel channel)
    {
        _connection = connection;
        _channel = channel;
    }

    public static async Task<RabbitMqLegalEventBus> CreateAsync(string connectionString)
    {
        var factory = new ConnectionFactory { Uri = new Uri(connectionString) };
        var connection = await factory.CreateConnectionAsync();
        var channel = await connection.CreateChannelAsync();
        return new RabbitMqLegalEventBus(connection, channel);
    }

    public async Task PublishAsync<T>(T @event, CancellationToken ct = default)
        where T : ILegalEvent
    {
        var exchangeName = ExchangeNameFor<T>();
        await _channel.ExchangeDeclareAsync(exchangeName, ExchangeType.Fanout, durable: true);

        var body = JsonSerializer.SerializeToUtf8Bytes(@event);
        var props = new BasicProperties
        {
            ContentType = "application/json",
            DeliveryMode = DeliveryModes.Persistent,
            MessageId = @event.EventId.ToString(),
            Timestamp = new AmqpTimestamp(@event.OccurredAt.ToUnixTimeSeconds()),
        };

        await _channel.BasicPublishAsync(exchangeName, routingKey: "", mandatory: false, props, body);
    }

    public IDisposable Subscribe<T>(Func<T, ILegalEventBus, CancellationToken, Task> handler)
        where T : ILegalEvent
    {
        var exchangeName = ExchangeNameFor<T>();
        _channel.ExchangeDeclareAsync(exchangeName, ExchangeType.Fanout, durable: true).GetAwaiter().GetResult();

        var queueName = _channel.QueueDeclareAsync(queue: "", exclusive: true).GetAwaiter().GetResult().QueueName;
        _channel.QueueBindAsync(queueName, exchangeName, routingKey: "").GetAwaiter().GetResult();

        var consumer = new AsyncEventingBasicConsumer(_channel);
        consumer.ReceivedAsync += async (_, ea) =>
        {
            var evt = JsonSerializer.Deserialize<T>(ea.Body.Span)!;
            await handler(evt, this, CancellationToken.None);
            await _channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
        };

        _channel.BasicConsumeAsync(queueName, autoAck: false, consumer).GetAwaiter().GetResult();
        return new RabbitMqSubscription(_channel, queueName);
    }

    private static string ExchangeNameFor<T>() =>
        $"metacratie.legal.{typeof(T).Name.ToLowerInvariant()}";

    public async ValueTask DisposeAsync()
    {
        await _channel.CloseAsync();
        await _connection.CloseAsync();
    }

    private sealed class RabbitMqSubscription(IChannel channel, string queueName) : IDisposable
    {
        public void Dispose() => channel.QueueDeleteAsync(queueName).GetAwaiter().GetResult();
    }
}

Ce qui change : la sérialisation (JSON), le transport (AMQP), la durabilité (persistent delivery). Ce qui ne change pas : l'interface, le contrat, les tests. Le test bout-en-bout vert avec InMemory garantit que la logique est correcte ; l'adaptateur RabbitMQ ajoute la distribution réseau.

L'injection au démarrage :

// En démonstration / tests :
services.AddSingleton<ILegalEventBus, InMemoryLegalEventBus>();

// En production :
services.AddSingleton<ILegalEventBus>(sp =>
    RabbitMqLegalEventBus.CreateAsync("amqp://guest:guest@localhost:5672/").Result);

Une ligne change. Le reste du code — LawCorpus, CitizenCaseEvaluator, les handlers, les événements — est identique.


9. Request/Response — quand un DSL attend une réponse pratique

Le pub/sub (sections 1-8) est du fire-and-forget : Law.Dsl publie, Citizen.Dsl réagit, personne n'attend personne. Mais certains cas exigent une réponse concrète. Le citoyen pose une question au droit et a besoin d'une réponse avant de poursuivre.

Le cas concret

Mathilde Martin veut savoir : « Au regard de l'article L.210-1, suis-je éligible à une aide pour l'accès à l'eau potable, compte tenu de mes revenus et de ma commune ? »

Ce n'est pas une notification. C'est une query — une question typée qui attend une réponse typée.

Les types de la query et de la réponse

La query vit dans Citizen.Dsl.Events (le demandeur possède sa question) :

// Citizen.Dsl.Events/EligibilityQuery.cs
using Metacratie.Common;
using Metacratie.Common.SharedKernel;

namespace Citizen.Dsl.Events;

/// <summary>
/// Le citoyen demande à Law.Dsl : suis-je éligible à un droit
/// au regard d'un article donné ?
/// </summary>
[PublishesEvent("Citizen")]
public sealed record EligibilityQuery : ILegalQuery<EligibilityResponse>
{
    public required Guid           QueryId       { get; init; }
    public required JurisdictionId Jurisdiction  { get; init; }
    public required CitizenCaseId  CaseId        { get; init; }
    public required ArticleFqn     Article       { get; init; }
    public required decimal        AnnualIncome  { get; init; }
    public required string         CommuneInsee  { get; init; }
}

La réponse vit dans Law.Citizen.Bridge.Events — le package bridge partagé entre les deux DSLs. Ni Law ni Citizen ne possèdent la réponse unilatéralement : elle est le produit de leur interaction.

// Law.Citizen.Bridge.Events/EligibilityResponse.cs
using Metacratie.Common;
using Metacratie.Common.SharedKernel;

namespace Law.Citizen.Bridge.Events;

/// <summary>
/// La réponse de Law.Dsl à une query d'éligibilité.
/// Produite par le bridge — package partagé entre Law et Citizen.
/// </summary>
public sealed record EligibilityResponse : ILegalResponse
{
    public required Guid            QueryId       { get; init; }
    public required bool            IsSuccessful  { get; init; }
    public required bool            IsEligible    { get; init; }
    public required ArticleFqn      Article       { get; init; }
    public required EligibilityBasis Basis         { get; init; }
    public          string?         DenialReason  { get; init; }
}

/// <summary>Base juridique de la décision — pas de string.</summary>
public enum EligibilityBasis
{
    IncomeThreshold,
    GeographicZone,
    FamilyComposition,
    DisabilityStatus,
    NotApplicable
}

Le responder dans Law.Dsl

Law.Dsl déclare qu'il sait répondre à cette query via [RespondsToQuery] :

// Law.Dsl/LawEligibilityResponder.cs
using Citizen.Dsl.Events;
using Law.Citizen.Bridge.Events;
using Metacratie.Common;
using Metacratie.Common.SharedKernel;

namespace Law.Dsl;

public static class LawEligibilityResponder
{
    /// <summary>
    /// Handler statique déclaré. Le Source Generator câblera automatiquement
    /// cette méthode comme responder de EligibilityQuery sur le bus.
    /// </summary>
    [RespondsToQuery(typeof(EligibilityQuery))]
    public static Task<EligibilityResponse> HandleAsync(
        EligibilityQuery query, CancellationToken ct)
    {
        // Logique simplifiée pour la démo.
        // En production : le Source Generator génère cette logique
        // à partir des acceptance criteria de l'article.
        var eligible = query.Article == new ArticleFqn("L.210-1", NormLevel.Loi)
                    && query.AnnualIncome < 15_000m;

        return Task.FromResult(new EligibilityResponse
        {
            QueryId      = query.QueryId,
            IsSuccessful = true,
            IsEligible   = eligible,
            Article      = query.Article,
            Basis        = eligible
                ? EligibilityBasis.IncomeThreshold
                : EligibilityBasis.NotApplicable,
            DenialReason = eligible ? null : "Revenu supérieur au seuil de 15 000 €/an",
        });
    }
}

Le test

[Fact]
public async Task Citizen_queries_law_for_eligibility_and_gets_typed_response()
{
    ILegalEventBus bus = new InMemoryLegalEventBus();

    // Law.Dsl enregistre son responder
    bus.Respond<EligibilityQuery, EligibilityResponse>(
        LawEligibilityResponder.HandleAsync);

    // Citizen.Dsl pose la question
    var response = await bus.QueryAsync<EligibilityQuery, EligibilityResponse>(
        new EligibilityQuery
        {
            QueryId      = Guid.NewGuid(),
            Jurisdiction = JurisdictionId.France,
            CaseId       = new CitizenCaseId("CASE-001"),
            Article      = new ArticleFqn("L.210-1", NormLevel.Loi),
            AnnualIncome = 12_000m,
            CommuneInsee = "75056",  // Paris
        });

    // La réponse est typée, structurée, vérifiable
    Assert.True(response.IsSuccessful);
    Assert.True(response.IsEligible);
    Assert.Equal(EligibilityBasis.IncomeThreshold, response.Basis);
    Assert.Null(response.DenialReason);
}

[Fact]
public async Task Citizen_above_income_threshold_gets_denial_with_reason()
{
    ILegalEventBus bus = new InMemoryLegalEventBus();
    bus.Respond<EligibilityQuery, EligibilityResponse>(
        LawEligibilityResponder.HandleAsync);

    var response = await bus.QueryAsync<EligibilityQuery, EligibilityResponse>(
        new EligibilityQuery
        {
            QueryId      = Guid.NewGuid(),
            Jurisdiction = JurisdictionId.France,
            CaseId       = new CitizenCaseId("CASE-004"),
            Article      = new ArticleFqn("L.210-1", NormLevel.Loi),
            AnnualIncome = 45_000m,
            CommuneInsee = "13055",  // Marseille
        });

    Assert.True(response.IsSuccessful);
    Assert.False(response.IsEligible);
    Assert.Equal(EligibilityBasis.NotApplicable, response.Basis);
    Assert.Equal("Revenu supérieur au seuil de 15 000 €/an", response.DenialReason);
}

Le point essentiel : la query et la réponse sont des records typés dans des packages NuGet séparés. Citizen.Dsl ne connaît pas LawCorpus. Il connaît EligibilityQuery (dans son propre package) et EligibilityResponse (dans le package bridge). Tout le reste est découplé.

Architecture des packages — mise à jour

Law.Citizen.Bridge.Events/                     ← NOUVEAU : le package bridge
├── EligibilityResponse.cs                     ← types de réponse partagés
├── EligibilityBasis.cs                        ← enum partagée
└── Law.Citizen.Bridge.Events.csproj           ← dépend de Metacratie.Common uniquement

Citizen.Dsl.Events/                            ← le demandeur possède ses questions
├── CitizenRightsUpdated.cs
├── EligibilityQuery.cs                        ← NOUVEAU
└── Citizen.Dsl.Events.csproj                  ← dépend de Metacratie.Common
                                                  + Law.Citizen.Bridge.Events (pour TResponse)

Le problème

Law.Dsl et Citizen.Dsl sont des DSLs racines — des interfaces abstraites. En production, on travaille avec des instances générées : Law.France2026.Etalab.Dsl, Citizen.France2026.Dsl. Ces instances sont produites par les méta-générateurs (LawDslGenerator, CitizenDslGenerator).

Question : quand deux DSLs générés doivent se parler, qui produit les types d'événements et les contrats de communication entre eux ?

La réponse : le méta-générateur

Le méta-générateur ne produit pas seulement le DSL. Il produit tout ce qui est nécessaire pour que le DSL communique avec les autres DSLs du système. Concrètement :

LawDslGenerator lit les attributs [LawArticle] et génère :

Law.France2026.Etalab.Dsl/                     ← le DSL instance (déjà existant)
Law.France2026.Etalab.Dsl.Events/              ← GÉNÉRÉ : les événements de cette instance
├── LawFrance2026ArticleAmended.g.cs           ← événement concret, typé pour France 2026
├── LawFrance2026ArticleRepealed.g.cs
└── LawFrance2026EventRegistry.g.cs            ← registre des événements publiés

Le registre est une classe statique générée qui liste tous les types d'événements :

// Law.France2026.Etalab.Dsl.Events/LawFrance2026EventRegistry.g.cs
// <auto-generated by LawDslGenerator />
namespace Law.France2026.Etalab.Dsl.Events;

/// <summary>
/// Registre des événements publiés par Law.France2026.Etalab.Dsl.
/// Généré automatiquement. Ne pas modifier.
/// </summary>
public static class LawFrance2026EventRegistry
{
    public static readonly IReadOnlyList<Type> PublishedEvents =
    [
        typeof(LawFrance2026ArticleAmended),
        typeof(LawFrance2026ArticleRepealed),
    ];

    public static readonly JurisdictionId Jurisdiction = JurisdictionId.France;
    public static readonly DateTimeOffset ValidFrom = new(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(1));
}

Quand LawDslGenerator détecte un bridge déclaré vers Citizen, il génère aussi :

Law.Citizen.France2026.Bridge.Events/           ← GÉNÉRÉ : le bridge entre deux instances
├── France2026EligibilityResponse.g.cs          ← réponse concrète pour France 2026
├── France2026BridgeEventRegistry.g.cs
└── Law.Citizen.France2026.Bridge.Events.csproj

Ce que le Source Generator vérifie au compile-time

Le générateur ne fait pas que produire du code. Il vérifie la cohérence du graphe de communication :

  1. Tout [PublishesEvent] sur une instance a un type correspondant dans le package .Events généré. Pas d'événements fantômes.

  2. Tout [ListensToEvent(typeof(T))] référence un type qui existe dans un package .Events importé. Pas de listeners morts pointant vers des événements supprimés.

  3. Tout [RespondsToQuery(typeof(T))] a la bonne signature. La méthode doit retourner Task<TResponse>TResponse est le type associé à ILegalQuery<TResponse>. Mismatch = compile error.

  4. Le graphe de dépendances ne contient pas de cycle. Si Law.Dsl.Events dépend de Citizen.Dsl.Events qui dépend de Law.Dsl.Events, le générateur émet une erreur META_CYCLE_001. Les bridges existent précisément pour briser ces cycles.

Le schéma complet

                    Metacratie.Common (SharedKernel)
                              │
                 ┌────────────┼────────────┐
                 │            │            │
         Law.Dsl.Events   Common.Dsl   Citizen.Dsl.Events
              │                              │
              │         ┌────────────────────┤
              │         │                    │
    LawDslGenerator  (bridge detecté)  CitizenDslGenerator
         │              │                    │
         ▼              ▼                    ▼
  Law.France2026   Law.Citizen.France2026  Citizen.France2026
  .Etalab.Dsl     .Bridge.Events          .Dsl
  .Events         (query/response          .Events
  (pub/sub)        générés)               (pub/sub)

Tout ce qui est sous la ligne du milieu est généré. Le développeur écrit les racines (Law.Dsl, Citizen.Dsl) et les attributs. Le méta-générateur produit les instances, les événements concrets, les bridges, et les registres. Si un type manque, si une signature ne correspond pas, si un cycle existe — le compilateur refuse de produire le code.

C'est l'Open/Closed (SOLID O) à l'échelle du système : les racines sont fermées, les instances générées sont des extensions, et l'infrastructure de communication est produite automatiquement par le générateur.


11. Ce que les quality gates vérifient

L'IA a participé à l'écriture de ce code. Les garde-fous existent :

Au compile-time (Source Generator, à terme) :

  • Tout record marqué [PublishesEvent] implémente ILegalEvent.
  • Toute méthode marquée [ListensToEvent(typeof(T))] a la signature static Task(T, ILegalEventBus, CancellationToken).
  • Tout événement publié a un [PublishesEvent] correspondant — pas d'événements orphelins.
  • Tout listener référence un événement existant — pas de listeners morts.

Au test-time (Partie 11 — QualityGate) :

  • Couverture par événement : chaque [PublishesEvent] a au moins un test qui vérifie sa propagation.
  • Couverture par listener : chaque [ListensToEvent] a au moins un test positif et un test négatif (événement non pertinent).
  • Tests de propriété (property-based) : pour N articles aléatoires, les événements émis sont exactement ceux attendus.

Au review-time (typed specs, Partie 14) :

  • Chaque événement a une acceptance criterion traçable.
  • Le rapport de compliance (Partie 11) vérifie que la couverture AC ≥ seuil.
  • Mutation testing : les mutants sont tués par les tests existants.

L'IA écrit vite. Les quality gates vérifient que ce qu'elle a écrit est correct. Le développeur valide que c'est pertinent.


12. Bilan

Ce round a produit du code compilable qui démontre :

Propriété Comment elle est garantie
Shared Kernel typé ArticleFqn, JurisdictionId, CitizenCaseId, NormLevel, CitizenRightStatus — records et enums, pas de strings
Événements typés sealed record immutables, implémentant ILegalEvent, marqués [PublishesEvent]
Déclaration de listeners [ListensToEvent(typeof(T))] sur méthode statique
Request/Response ILegalQuery<TResponse> + [RespondsToQuery] — quand un DSL attend une réponse concrète
Pub/sub + Query interfacés ILegalEventBus (port) + InMemoryLegalEventBus (adaptateur démo) + RabbitMqLegalEventBus (adaptateur production)
Packages séparés Law.Dsl.Events, Citizen.Dsl.Events, Law.Citizen.Bridge.Events — chacun ses préoccupations
Découplage Citizen.Dsl dépend de Law.Dsl.Events, jamais de Law.Dsl
Chaîne d'événements LawArticleAmended → handler → CitizenRightsUpdated
Méta-générateur Produit les .Events, les bridges, et les registres des instances générées. Vérifie les cycles, les signatures, les types manquants.
Testabilité 5 tests bout-en-bout sans infrastructure, déterministes, rapides
Substituabilité InMemory ↔ RabbitMQ, même interface, même tests

La critique « montrez du code » est comblée. Le shared kernel garantit le type safety — si un identifiant se rapporte à un concept du domaine, c'est un record nommé, pas une string. Le méta-générateur garantit que les DSLs générés peuvent communiquer entre eux sans câblage manuel. Le pattern est posé.

⬇ Download