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:2px

L'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&lt;T&gt; + [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 ProjectReference normal. Il faut impérativement utiliser OutputItemType="Analyzer" et ReferenceOutputAssembly="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éfixe Get grâ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 un 404 NotFound si null)

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&lt;Product?&gt;<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 :

  1. Une classe static partial décorée de [MapApiExtension] — c'est la cible où le code sera injecté
  2. 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 le transform qui 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.Error plutô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.0 sont 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 :

  1. Le débogage des générateurs est pénible. Pas de Console.WriteLine, pas de breakpoint simple. J'utilise System.Diagnostics.Debugger.Launch() dans le constructeur du générateur pour attacher le débogueur de Visual Studio au processus du compilateur.

  2. Le cache incrémental est subtil. Si vos objets intermédiaires (comme EndpointApiInfo) n'implémentent pas correctement Equals/GetHashCode, le générateur se redéclenchera à chaque frappe. Ou pire, il ne se redéclenchera pas quand il devrait.

  3. netstandard2.0 signifie pas de Span<T>, pas de Range, pas de record. J'utilise LangVersion: latest pour 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.