Introduction
Quand j'ai commencé à travailler sur ChannelMediator, mon objectif était clair : proposer un médiateur ultra-performant construit sur System.Threading.Channels. Mais très vite, un problème récurrent est apparu — le boilerplate. Chaque nouvelle requête nécessitait de câbler manuellement un endpoint Minimal API côté serveur, puis d'écrire un handler HttpClient côté client. Deux fichiers, le même contrat, les mêmes patterns répétés à l'infini. C'est là que les Source Generators de Roslyn sont entrés en jeu.
Dans cet article, je vais vous raconter comment j'ai conçu deux générateurs de code complémentaires : l'un qui transforme un simple record décoré d'un attribut en endpoint Minimal API, l'autre qui génère automatiquement le client HTTP correspondant. Le tout orchestré par le pattern Mediator, sans aucune réflexion au runtime.
La génération de code dans l'écosystème .NET
Avant de plonger dans mon implémentation, faisons un tour d'horizon des différentes approches de génération de code disponibles dans .NET moderne.
Les Source Generators Roslyn (IIncrementalGenerator)
Introduits avec .NET 5 et considérablement améliorés depuis .NET 6 avec l'API incrémentale (IIncrementalGenerator), les Source Generators sont des analyseurs qui s'exécutent pendant la compilation. Ils inspectent l'arbre syntaxique (Syntax Tree) et le modèle sémantique (Semantic Model) du code source, puis injectent de nouveaux fichiers .cs directement dans la compilation. Aucun fichier n'est écrit sur disque (sauf en mode EmitCompilerGeneratedFiles), aucune réflexion au runtime.
C'est l'approche que j'ai choisie pour ChannelMediator, et je vais vous expliquer pourquoi.
Les Interceptors (preview depuis .NET 8)
Les Interceptors permettent de remplacer un appel de méthode existant par un autre au moment de la compilation. Contrairement aux Source Generators qui ajoutent du code, les Interceptors redirigent du code. Ils sont utilisés en interne par ASP.NET Core pour optimiser le mapping des routes. C'est une fonctionnalité encore en preview et assez contraignante en termes de positionnement (il faut spécifier la ligne et la colonne exactes de l'appel intercepté).
T4 Templates et dotnet-templating
Les bons vieux templates T4 (.tt) existent toujours et fonctionnent, j'ai utilisé ça dans un ERP qu'on avait développé à partir de 2010 (ERP360) c'était super pratique, mais à coder, une veritable horreur de syntaxe.
Ils génèrent du code avant la compilation, nécessitent souvent un trigger manuel, et n'ont pas accès au modèle sémantique.
Code Generators tiers (RazorLigth, HandleBars, etc.)
Des outils comme RazorLight et HandlerBars que j'utisais jusqu'a très recemment, fonctionnent par templating — un peu comme T4 mais avec une syntaxe plus moderne. Ils sont souvent utilisés pour générer du code à partir de fichiers de configuration ou de modèles externes. Cependant, ils ne s'intègrent pas nativement dans le processus de compilation et nécessitent souvent des scripts ou des étapes manuelles pour déclencher la génération.
Pourquoi j'ai choisi IIncrementalGenerator
Le choix s'est imposé naturellement :
- Zéro coût au runtime : tout est résolu à la compilation
- Intégration IDE native : IntelliSense, Go-to-Definition, erreurs en temps réel
- API incrémentale : seuls les fichiers modifiés déclenchent une regénération
- Diagnostics personnalisés : je peux émettre des erreurs et warnings directement dans l'IDE
flowchart LR
A[Code source<br/>.cs files] --> B[Compilateur Roslyn]
B --> C{Source Generator}
C --> D[Analyse Syntax Tree<br/>+ Semantic Model]
D --> E[Génération de<br/>fichiers .g.cs]
E --> B
B --> F[Assembly finale<br/>.dll]
style C fill:#f96,stroke:#333,stroke-width:2px
style E fill:#6f9,stroke:#333,stroke-width:2pxL'architecture : pourquoi .NET Standard 2.0 pour les abstractions
C'est une des premières leçons que j'ai apprises — et elle m'a coûté quelques heures de débogage. Un Source Generator Roslyn doit cibler netstandard2.0. C'est une contrainte imposée par le compilateur lui-même : le générateur est chargé dans le processus du compilateur, qui s'attend à du .NET Standard 2.0.
Mais le piège ne s'arrête pas là. Les attributs que le générateur doit détecter doivent eux aussi être dans un assembly compatible avec tous les projets consommateurs. Si vos contrats (les IRequest<T>) ciblent net8.0, net9.0 et net10.0, l'assembly d'abstractions doit être compatible avec tous.
C'est pourquoi j'ai créé un projet dédié ChannelMediator.MinimalApiGenerator.Abstraction qui cible netstandard2.0 :
<!-- ChannelMediator.MinimalApiGenerator.Abstraction.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Ce projet contient uniquement les trois attributs marqueurs — aucune dépendance, aucune logique :
graph TB subgraph "netstandard2.0" ABS["ChannelMediator.MinimalApiGenerator.Abstraction<br/>─────────────────<br/>• EndpointApiAttribute<br/>• MapApiExtensionAttribute<br/>• ApiClientAttribute"] GEN_API["ChannelMediator.MinimalApiGenerator<br/>(Source Generator)"] GEN_CLIENT["ChannelMediator.ApiClientGenerator<br/>(Source Generator)"] end subgraph "net8.0 / net9.0 / net10.0" CONTRACTS["Contrats<br/>─────────────────<br/>IRequest<T> + [EndpointApi]"] API["Minimal API Server"] CLIENT["API Client Console/Blazor"] end CONTRACTS --> ABS API --> CONTRACTS API -.->|Analyzer| GEN_API CLIENT --> CONTRACTS CLIENT -.->|Analyzer| GEN_CLIENT GEN_API --> ABS GEN_CLIENT --> ABS style ABS fill:#ffd700,stroke:#333 style GEN_API fill:#ff6b6b,stroke:#333 style GEN_CLIENT fill:#ff6b6b,stroke:#333
Astuce : Une erreur classique est de référencer le Source Generator comme un
ProjectReferencenormal. Il faut impérativement utiliserOutputItemType="Analyzer"etReferenceOutputAssembly="false":<ProjectReference Include="...\MinimalApiGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
Le contrat : un record, un attribut, c'est tout
L'idée centrale est que le contrat (la requête) est la source de vérité unique. Un simple record décoré de [EndpointApi] suffit à décrire à la fois l'endpoint API et le client HTTP :
[EndpointApi(
GroupName = "Catalog",
EntityName = "products",
UseHttpStandardVerbs = true)]
public record GetProductRequest(int Id) : IRequest<Product?>;
Cette seule ligne encode :
- Le verbe HTTP :
GET(déduit du préfixeGetgrâce àUseHttpStandardVerbs) - La route :
/api/catalog/products?Id=... - Les paramètres : extraits des paramètres du constructeur primaire du record
- Le type de retour :
Product?(nullable → le générateur produira un404 NotFoundsinull)
Voici les propriétés disponibles sur l'attribut [EndpointApi] :
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class EndpointApiAttribute : Attribute
{
public string GroupName { get; set; } // Groupe de routes (/api/{group})
public string EntityName { get; set; } // Segment d'entité (/api/group/{entity})
public string[] Tags { get; set; } // Tags OpenAPI
public string? Summary { get; set; } // Résumé Swagger
public string? Description { get; set; } // Description détaillée
public string[] AuthenticationSchemes { get; set; } // Schémas d'auth
public bool UseHttpStandardVerbs { get; set; } // Déduction du verbe HTTP
}
flowchart LR subgraph "Contrat partagé" R["record GetProductRequest(int Id)<br/>: IRequest<Product?><br/>[EndpointApi]"] end R -->|Source Generator<br/>MinimalApiGenerator| S["GET /api/catalog/products<br/>→ mediator.Send(request)"] R -->|Source Generator<br/>ApiClientGenerator| C["HttpClient.GetFromJsonAsync<br/>→ mediator.Send(request)"] style R fill:#4ecdc4,stroke:#333,stroke-width:2px style S fill:#ff6b6b,stroke:#333 style C fill:#45b7d1,stroke:#333
Le MinimalApiGenerator : du record au MapGet
Le premier générateur, MinimalApiGenerator, scanne la compilation à la recherche de deux choses :
- Une classe
static partialdécorée de[MapApiExtension]— c'est la cible où le code sera injecté - Tous les types décorés de
[EndpointApi]— les endpoints à générer
Le pipeline incrémental
J'utilise l'API IIncrementalGenerator qui fonctionne en deux phases : un filtre syntaxique rapide (sans modèle sémantique) puis une transformation sémantique plus coûteuse. Cela garantit que le générateur ne se déclenche que quand c'est nécessaire.
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var mapApiExtensionClasses = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (s, _) => IsMapApiExtensionClass(s),
transform: static (ctx, _) => GetMapApiExtensionClass(ctx))
.Where(static m => m is not null);
var endpointApiClasses = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (s, _) => IsEndpointApiClass(s),
transform: static (ctx, _) => GetEndpointApiClass(ctx))
.Where(static m => m is not null);
var allData = context.CompilationProvider
.Combine(mapApiExtensionClasses.Collect())
.Combine(endpointApiClasses.Collect());
context.RegisterSourceOutput(allData,
static (spc, source) => Execute(...));
}
Anecdote : Le
predicate(filtre syntaxique) est appelé sur chaque nœud de l'arbre syntaxique à chaque frappe clavier dans l'IDE. Il doit être extrêmement rapide. C'est pourquoi je ne vérifie que la présence d'AttributeLists— le vrai travail d'identification de l'attribut se fait dans letransformqui n'est appelé que sur les candidats filtrés.
Déduction du verbe HTTP par convention de nommage
Un des mécanismes que j'apprécie particulièrement est la déduction automatique du verbe HTTP. Quand UseHttpStandardVerbs = true, le générateur analyse le préfixe du nom du type :
| Préfixe | Verbe HTTP |
|---|---|
Get* |
GET |
Delete* |
DELETE |
Put*, Update* |
PUT |
Post*, Create*, Save* |
POST |
Pour les verbes GET et DELETE, les paramètres du constructeur primaire sont extraits et passés en query string. Pour POST et PUT, le corps entier de la requête est envoyé en JSON dans le body.
Le code généré côté serveur
Pour notre GetProductRequest, voici ce que le générateur produit :
// Fichier généré : MapMyRequestsMapper.g.cs
using ChannelMediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Http;
namespace ChannelMediatorMinimalApiSample;
public static partial class MyRequestsMapper
{
public static void MapMyRequestsMapper(this IEndpointRouteBuilder routes)
{
// Group: Catalog
var catalogGroup = routes.MapGroup("/api/catalog");
catalogGroup.MapGet("/products", async (int Id, IMediator mediator) =>
{
var result = await mediator.Send(new GetProductRequest(Id));
return result is not null
? Results.Ok(result)
: Results.NotFound();
})
.Produces<Product>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
catalogGroup.MapPost("/products", async (HttpRequest httpRequest,
IMediator mediator, SaveProductRequest request)
=> await mediator.Send(request));
catalogGroup.MapDelete("/products", async (int Id, IMediator mediator)
=> await mediator.Send(new DeleteProductRequest(Id)))
.WithSummary("Delete a product")
.WithTags("Catalog")
.WithDescription("Delete a product by ID");
}
}
Le point clé ici : le générateur gère intelligemment les types de retour nullable. Quand IRequest<Product?> a une réponse nullable, il génère un bloc if/else avec Results.Ok() / Results.NotFound() et ajoute les métadonnées OpenAPI .Produces<T>(200) et .Produces<ProblemDetails>(404).
Diagnostics personnalisés : des erreurs claires dans l'IDE
J'ai implémenté quatre diagnostics personnalisés qui apparaissent directement dans la Error List de Visual Studio :
| Code | Description |
|---|---|
CMAPI001 |
La classe [MapApiExtension] n'est pas static |
CMAPI002 |
La classe [MapApiExtension] n'est pas partial |
CMAPI003 |
Le GroupName contient des caractères invalides |
CMAPI004 |
Le EntityName est invalide pour un segment d'URL |
private static readonly DiagnosticDescriptor NotStaticDescriptor = new(
id: "CMAPI001",
title: "MapApiExtension class must be static",
messageFormat: "Class '{0}' decorated with [MapApiExtension] must be declared as 'static'.",
category: "ChannelMediator.MinimalApiGenerator",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
Astuce : Émettre des
DiagnosticSeverity.Errorplutôt que des warnings est crucial. Cela empêche la compilation si la configuration est invalide, au lieu de produire du code cassé silencieusement.
Scan des assemblies référencées
Une fonctionnalité importante : le générateur ne se limite pas aux types du projet courant. Grâce à la propriété ScanAssemblies de [MapApiExtension], il peut scanner les assemblies référencées pour y trouver les types [EndpointApi]. C'est ce qui permet d'avoir les contrats dans un projet séparé :
[MapApiExtension(ScanAssemblies = new[] { "ChannelMediatorApiContractsSample" })]
public static partial class MyRequestsMapper { }
Le générateur parcourt alors compilation.SourceModule.ReferencedAssemblySymbols pour y découvrir les types décorés, en utilisant exactement la même logique d'extraction que pour les types locaux.
L'ApiClientGenerator : du même record au HttpClient
Le second générateur est le miroir du premier. Il prend les mêmes contrats et génère des IRequestHandler<TRequest, TResponse> qui appellent l'API via HttpClient.
Déclenchement par attribut assembly-level
Côté client, un attribut au niveau de l'assembly désigne quel assembly contient les contrats :
[assembly: ApiClient(typeof(GetProductRequest), HttpClientName = "ApiClient")]
Le typeof(GetProductRequest) sert de "point d'entrée" pour que le générateur puisse retrouver l'assembly contenant les contrats. Le HttpClientName correspond au client nommé enregistré dans le DI.
Le code généré côté client
Pour GetProductRequest, voici le handler généré :
// Fichier généré : GetProductRequestHandler.g.cs
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using ChannelMediator;
namespace ChannelMediatorApiClientSample.Handlers;
/// <summary>Generated API client handler for <see cref="GetProductRequest"/>.</summary>
internal class GetProductRequestHandler
: IRequestHandler<GetProductRequest, Product>
{
private readonly IHttpClientFactory _httpClientFactory;
public GetProductRequestHandler(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<Product> Handle(
GetProductRequest request, CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient("ApiClient");
var url = $"{httpClient.BaseAddress}catalog/products?Id={request.Id}";
var result = await httpClient.GetFromJsonAsync<Product>(
url, System.Text.Json.JsonSerializerOptions.Web, cancellationToken);
return result!;
}
}
Le générateur produit également une classe ClientApiException pour encapsuler les erreurs HTTP dans les cas POST/PUT/DELETE :
public class ClientApiException : Exception
{
public HttpResponseMessage Response { get; }
public ClientApiException(string message, HttpResponseMessage response)
: base(message) => Response = response;
}
La symétrie parfaite
Ce qui est élégant, c'est que le consommateur ne sait pas s'il parle à un handler local ou à une API distante. Le code appelant est identique :
// Côté serveur — le handler est local
var product = await mediator.Send(new GetProductRequest(1));
// Côté client — le handler généré appelle l'API via HTTP
var product = await mediator.Send(new GetProductRequest(1));
sequenceDiagram participant App as Application participant Med as IMediator participant Chan as Channel Envelope participant Wrap as RequestHandlerWrapper participant H as Handler (local ou généré) participant API as API distante (si client) App->>Med: Send(new GetProductRequest(1)) Med->>Chan: Enqueue(envelope) Chan->>Wrap: Dequeue + resolve handler Wrap->>H: Handle(request, ct) alt Handler local (serveur) H-->>Wrap: Product else Handler généré (client HTTP) H->>API: GET /api/catalog/products?Id=1 API-->>H: 200 OK + JSON H-->>Wrap: Product (désérialisé) end Wrap-->>Med: TaskCompletionSource.SetResult Med-->>App: Product
Mise en pratique : du contrat au déploiement
Voyons le flux complet de mise en place.
Étape 1 : Définir les contrats dans un projet partagé
// ChannelMediatorApiContractsSample/Models/GetProductRequest.cs
[EndpointApi(
GroupName = "Catalog",
EntityName = "products",
UseHttpStandardVerbs = true)]
public record GetProductRequest(int Id) : IRequest<Product?>;
// ChannelMediatorApiContractsSample/Models/SaveProductRequest.cs
[EndpointApi(
GroupName = "Catalog",
EntityName = "products")]
public record SaveProductRequest(Product Product) : IRequest<Product>;
// ChannelMediatorApiContractsSample/Models/DeleteProductRequest.cs
[EndpointApi(
GroupName = "Catalog",
EntityName = "products",
UseHttpStandardVerbs = true,
Tags = new[] { "Catalog" },
Summary = "Delete a product",
Description = "Delete a product by ID")]
public record DeleteProductRequest(int Id) : IRequest<bool>;
Étape 2 : Côté serveur — écrire le handler métier et la classe mapper
Le handler métier est la seule logique à écrire manuellement :
// Handlers/GetProductHandler.cs
public class GetProductHandler : IRequestHandler<GetProductRequest, Product?>
{
public Task<Product?> Handle(
GetProductRequest request, CancellationToken cancellationToken)
{
if (request.Id == 999)
{
return Task.FromResult<Product?>(null);
}
return Task.FromResult<Product?>(new Product
{
Id = request.Id,
Name = $"Product {request.Id}",
Price = 99.99
});
}
}
La classe mapper est un shell vide — le générateur fait le reste :
[MapApiExtension]
public static partial class MyRequestsMapper { }
Et dans Program.cs :
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddChannelMediator(null, typeof(Program).Assembly);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapMyRequestsMapper(); // ← méthode générée !
await app.RunAsync();
Étape 3 : Côté client — une ligne et c'est prêt
[assembly: ApiClient(typeof(GetProductRequest), HttpClientName = "ApiClient")]
var services = new ServiceCollection();
services.AddChannelMediator(null, typeof(Program).Assembly);
services.AddHttpClient("ApiClient").ConfigureHttpClient(cfg =>
{
cfg.BaseAddress = new Uri("https://localhost:7031/api/");
});
var sp = services.BuildServiceProvider();
var mediator = sp.GetRequiredService<IMediator>();
// Appel transparent — le handler HTTP est généré automatiquement
var product = await mediator.Send(new GetProductRequest(1));
Console.WriteLine(product!.Name); // "Product 1"
Structure des projets et dépendances
ChannelMediator.sln
├── src/
│ ├── ChannelMediator.Contracts/ (net8.0;net9.0;net10.0)
│ │ └── IRequest<T>, INotification, etc.
│ ├── ChannelMediator/ (net8.0;net9.0;net10.0)
│ │ └── Mediator engine
│ ├── ChannelMediator.MinimalApiGenerator.Abstraction/ (netstandard2.0) ⚠️
│ │ └── EndpointApiAttribute, MapApiExtensionAttribute, ApiClientAttribute
│ ├── ChannelMediator.MinimalApiGenerator/ (netstandard2.0) ⚠️
│ │ └── Source Generator → MapGet/MapPost/MapPut/MapDelete
│ ├── ChannelMediator.ApiClientGenerator/ (netstandard2.0) ⚠️
│ │ └── Source Generator → IRequestHandler via HttpClient
│ ├── ChannelMediatorApiContractsSample/ (net10.0)
│ │ └── GetProductRequest, SaveProductRequest, etc.
│ └── Samples/
│ ├── ChannelMediatorMinimalApiSample/ (net10.0 - Web)
│ └── ChannelMediatorApiClientSample/ (net10.0 - Console)
⚠️ Les trois projets en
netstandard2.0sont ceux qui interagissent avec Roslyn.
Conclusion : compilation-time vs runtime, le verdict
Après avoir mis en place ces générateurs, voici le bilan que je tire.
Ce que le compile-time apporte concrètement
Zéro réflexion, zéro coût au démarrage. Les générateurs produisent du code statique, directement compilé dans l'assembly finale. Le DI container résout directement les types concrets — pas de Type.GetInterfaces(), pas de MakeGenericType(), pas de cache de delegates compilés à chaud. Le démarrage de l'application est instantané, sans phase de scan ou d'initialisation dynamique.
Erreurs à la compilation, pas au runtime. Si un GroupName contient un caractère invalide, vous le savez immédiatement dans l'IDE. Si la classe mapper n'est pas static partial, le build échoue avec un message clair. Comparez cela avec une exception InvalidOperationException au démarrage de l'application en production...
IntelliSense et navigation. Le code généré est du vrai code C#. Vous pouvez faire Ctrl+Click sur MapMyRequestsMapper() et voir exactement le code généré. Pas de magie noire, pas de proxy dynamique, pas de IL invisible.
Contrat unique, deux projections. Un seul record avec [EndpointApi] produit à la fois l'endpoint serveur et le client HTTP. Modifier le contrat (ajouter un paramètre, changer le type de retour) se propage automatiquement aux deux côtés à la prochaine compilation. Plus de désynchronisation entre API et client.
Les pièges à éviter
J'ai rencontré quelques difficultés en chemin que je partage volontiers :
Le débogage des générateurs est pénible. Pas de
Console.WriteLine, pas de breakpoint simple. J'utiliseSystem.Diagnostics.Debugger.Launch()dans le constructeur du générateur pour attacher le débogueur de Visual Studio au processus du compilateur.Le cache incrémental est subtil. Si vos objets intermédiaires (comme
EndpointApiInfo) n'implémentent pas correctementEquals/GetHashCode, le générateur se redéclenchera à chaque frappe. Ou pire, il ne se redéclenchera pas quand il devrait.netstandard2.0signifie pas deSpan<T>, pas deRange, pas derecord. J'utiliseLangVersion: latestpour bénéficier de la syntaxe moderne, mais certaines API du runtime ne sont pas disponibles. Par exemple,string.Contains(char)n'existe pas en .NET Standard 2.0.
Les chiffres
En benchmark BenchmarkDotNet, la dispatch d'une requête via ChannelMediator est de l'ordre de quelques microsecondes, sans aucune allocation supplémentaire liée à la résolution du handler — puisqu'il n'y a pas de résolution dynamique. Le code généré est aussi performant que du code écrit à la main, parce que c'est du code écrit à la main — par un robot.
graph LR subgraph "Approche traditionnelle (runtime)" R1[Démarrage] --> R2[Scan assemblies<br/>via réflexion] R2 --> R3[Création delegates<br/>MakeGenericType] R3 --> R4[Cache en mémoire] R4 --> R5[Dispatch] end subgraph "Approche Source Generator (compile-time)" C1[Compilation] --> C2[Analyse syntaxique<br/>+ sémantique] C2 --> C3[Génération .g.cs] C3 --> C4[Compilation finale] C4 --> C5[Dispatch direct<br/>zéro overhead] end style R2 fill:#ff6b6b,stroke:#333 style R3 fill:#ff6b6b,stroke:#333 style C5 fill:#6f9,stroke:#333
Le mot de la fin
Les Source Generators ne sont pas une silver bullet. Ils ajoutent de la complexité au tooling, le débogage est parfois frustrant, et la courbe d'apprentissage de l'API Roslyn est réelle. Mais pour un cas d'usage comme celui-ci — générer du code répétitif à partir d'un contrat fortement typé — c'est l'outil idéal.
Si je devais résumer en une phrase : un attribut, deux générateurs, zéro boilerplate, zéro réflexion. Le code que vous lisez est le code qui s'exécute, et il est généré à partir d'une source de vérité unique. C'est exactement la promesse des Source Generators, et avec ChannelMediator, elle est tenue.
Le code source complet est disponible sur GitHub. N'hésitez pas à explorer les projets ChannelMediator.MinimalApiGenerator et ChannelMediator.ApiClientGenerator pour voir l'implémentation en détail.