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.
Sommaire
- Architecture des packages
- Les événements typés — déclaration par attributs
- L'interface du bus — un port, pas une implémentation
- Implémentation InMemory — l'adaptateur de démonstration
- Law.Dsl — le DSL juridique et ses événements
- Citizen.Dsl — écouter, réagir, publier
- Le flux complet — test bout-en-bout
- L'adaptateur RabbitMQ — l'interface de production
- Request/Response — quand un DSL attend une réponse pratique
- Le méta-générateur produit la communication
- Ce que les quality gates vérifient
- Bilan
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.InMemoryMetacratie.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.InMemoryQuatre points essentiels :
Law.Dslne dépend jamais deCitizen.Dsl. Le découplage est garanti par la structure des packages.Citizen.Dsldépend deLaw.Dsl.Events(les messages), pas deLaw.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 ennetstandard2.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/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; }
}// 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; }
}// 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<TResponse>(TQuery, CancellationToken).
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class RespondsToQueryAttribute : Attribute
{
public Type QueryType { get; }
public RespondsToQueryAttribute(Type queryType) => QueryType = queryType;
}// 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<TResponse>(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;
}// 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 :
PublishAsyncest 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
LawArticleAmendedpeut publier unCitizenRightsUpdatedsur le même bus. IDisposablepour le désabonnement — calqué sur le patternIEventStream<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 _);
}
}// 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/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; }
}// 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);
}
}// 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; }
}// 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);// 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 :
CitizenCaseEvaluatordépend deLaw.Dsl.Events(les messages), pas deLaw.Dsl(la logique). Il ne connaît pasLawCorpus.- Le handler
OnLawArticleAmendedAsyncest 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);
}
}// 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 :
- Amendement qui renforce un droit → le citoyen est notifié, statut mis à jour.
- Amendement sans rapport → le citoyen n'est pas impacté, aucun événement parasite.
- 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();
}
}// 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);// 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; }
}// 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
}// 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",
});
}
}// 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);
}[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)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ésLaw.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ésLe 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));
}// 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.csprojLaw.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.csprojCe 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 :
Tout
[PublishesEvent]sur une instance a un type correspondant dans le package.Eventsgénéré. Pas d'événements fantômes.Tout
[ListensToEvent(typeof(T))]référence un type qui existe dans un package.Eventsimporté. Pas de listeners morts pointant vers des événements supprimés.Tout
[RespondsToQuery(typeof(T))]a la bonne signature. La méthode doit retournerTask<TResponse>oùTResponseest le type associé àILegalQuery<TResponse>. Mismatch = compile error.Le graphe de dépendances ne contient pas de cycle. Si
Law.Dsl.Eventsdépend deCitizen.Dsl.Eventsqui dépend deLaw.Dsl.Events, le générateur émet une erreurMETA_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) 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émenteILegalEvent. - Toute méthode marquée
[ListensToEvent(typeof(T))]a la signaturestatic 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é.