Stéphane De Todaro — tech lead

@super-dev.app · Lead-technique
Actif depuis 2017

Lead technique et architecte full-stack, freelance depuis 2019. Je conçois, industrialise et exploite des plateformes métier sur Azure, et je publie des moteurs logiciels open source.

Retour aux articles
λ
.NET
.NET

Générer du code à la compilation : les source generators

Article 5 sur 5 — Le .NET moderne
APIs minimales, CQRS, gRPC, source generators : du .NET 8 net et testable, sans usine à gaz.

La réflexion coûte au pire moment : au démarrage et à chaud, dans le code qui tourne en production. Un scan d'assembly au boot, un Activator.CreateInstance résolu à la volée, tout ça se paie au runtime. Les source generators déplacent ce travail à l'autre bout du cycle, à la compilation . Le générateur lit votre code, en produit d'autre, et le compilateur intègre le résultat dans l'assembly comme si vous l'aviez tapé à la main.

Incremental, pas l'ancienne API

La première génération d'API, ISourceGenerator , avait un défaut structurel : elle re-exécutait tout le générateur à chaque frappe. Sur un gros projet, chaque caractère tapé relançait l'analyse complète, et l'éditeur ralentissait à mesure que le code grossissait.

IIncrementalGenerator change le modèle. Au lieu d'une fonction qui produit du code, on décrit un pipeline de transformations. Roslyn met en cache la sortie de chaque étape et compare, à la frappe suivante, l'entrée d'une étape avec sa valeur précédente. Si elle est identique, l'étape n'est pas rejouée : sa sortie déjà calculée est réutilisée. Un commentaire ajouté dans une méthode ne touche pas le modèle sémantique que lit votre générateur, le pipeline s'arrête tôt, et rien n'est régénéré.

Tout le travail consiste donc à découper le traitement en étapes dont les entrées changent rarement, et à faire circuler entre elles des données comparables par valeur.

Le pipeline en deux temps

Un pipeline attribut-orienté commence par filtrer les millions de nœuds syntaxiques de la compilation. Le prédicat passe en premier : il doit être syntaxique et rapide, car il tourne sur chaque nœud. Il ne teste qu'une chose, sans modèle sémantique : la forme du nœud. La transformation vient ensuite, seulement sur les nœuds retenus, et là on a le droit d'être sémantique , de résoudre des symboles et de lire des attributs.

Depuis Roslyn 4.3, ForAttributeWithMetadataName court-circuite tout ce filtrage pour le cas le plus courant, la détection d'un attribut marqueur. Le compilateur maintient un index des attributs et ne présente au générateur que les nœuds réellement décorés, ce qui évite de parcourir l'arbre entier. C'est l'entrée à privilégier ; CreateSyntaxProvider reste là pour les cas qui ne reposent pas sur un attribut.

C#
1[Generator]
2public sealed class ServiceRegistrationGenerator : IIncrementalGenerator
3{
4 private const string Marker = "MyApp.RegisterScopedAttribute";
5
6 public void Initialize(IncrementalGeneratorInitializationContext context)
7 {
8 // Emit the marker attribute itself, before anything reads the syntax trees.
9 context.RegisterPostInitializationOutput(static ctx => ctx.AddSource(
10 "RegisterScopedAttribute.g.cs",
11 """
12 namespace MyApp;
13
14 [System.AttributeUsage(System.AttributeTargets.Class)]
15 internal sealed class RegisterScopedAttribute : System.Attribute;
16 """));
17
18 IncrementalValuesProvider<ServiceModel> services = context.SyntaxProvider
19 .ForAttributeWithMetadataName(
20 Marker,
21 predicate: static (node, _) => node is ClassDeclarationSyntax,
22 transform: static (ctx, _) => new ServiceModel(ctx.TargetSymbol.ToDisplayString()));
23
24 context.RegisterSourceOutput(services.Collect(), Emit);
25 }
26}

Le static sur chaque lambda est délibéré. Une lambda qui capture une variable transporte cet état dans le pipeline et peut y introduire une référence non comparable, ce qui fait manquer le cache. RegisterPostInitializationOutput sert à émettre l'attribut lui-même : il devient disponible pour le reste de la compilation sans que le consommateur ait à référencer un package séparé.

Ce qui casse le cache

Le cache repose entièrement sur l'égalité. Roslyn compare l'entrée d'une étape avec la précédente via EqualityComparer<T>.Default . Si le type ne définit pas une égalité de valeur correcte, chaque frappe ressemble à un changement, le pipeline se rejoue en entier, et tout le bénéfice de l'incrémental disparaît.

Deux pièges dominent. Le premier consiste à faire transiter un ISymbol , un SyntaxNode , une SemanticModel ou une Compilation d'une étape à l'autre. Ces objets n'ont pas d'égalité de valeur, et surtout ils maintiennent en vie toute la compilation dont ils sont issus. La transformation doit en extraire aussitôt ce dont elle a besoin, dans un petit modèle plat.

C#
1// A flat, value-equatable snapshot: no ISymbol, no SyntaxNode, no Compilation.
2// Holding any of those pins the whole compilation and defeats the cache.
3internal readonly record struct ServiceModel(string FullyQualifiedName);

Le second piège est plus discret. ImmutableArray<T> compare son tableau sous-jacent par référence, pas élément par élément : un modèle qui expose un ImmutableArray<T> en champ paraîtra modifié à chaque passe, même à contenu identique. La parade habituelle est un petit wrapper qui compare par SequenceEqual , souvent nommé EquatableArray<T> , que la plupart des générateurs sérieux embarquent.

Même prudence avec context.CompilationProvider : la Compilation change à chaque frappe. Le combiner directement à votre pipeline le fait tout recalculer. Si une seule information de la compilation vous est utile, réduisez-la d'abord avec Select vers une petite valeur comparable avant de la combiner.

Un cas concret : enregistrer la DI

Le scénario le plus rentable : marquer une classe d'un [RegisterScoped] , laisser le générateur produire l'appel AddScoped correspondant, et collecter le tout dans une méthode d'extension. Program.cs cesse de s'allonger à chaque service ajouté, et le scan d'assembly par réflexion au démarrage disparaît.

C#
1private static void Emit(SourceProductionContext context, ImmutableArray<ServiceModel> services)
2{
3 if (services.IsDefaultOrEmpty)
4 return;
5
6 var registrations = string.Join(
7 "\n ",
8 services.Select(s => $"services.AddScoped<{s.FullyQualifiedName}>();"));
9
10 context.AddSource("ServiceRegistrations.g.cs", $$"""
11 namespace MyApp;
12
13 public static class GeneratedServices
14 {
15 public static IServiceCollection AddGenerated(this IServiceCollection services)
16 {
17 {{registrations}}
18 return services;
19 }
20 }
21 """);
22}

Program.cs se réduit alors à builder.Services.AddGenerated(); . Le code produit est visible, débogable, et le compilateur le valide comme le vôtre. Activez <EmitCompilerGeneratedFiles> dans le .csproj pour retrouver les fichiers .g.cs sur le disque et les relire.

Remonter des diagnostics

Un générateur ne fait pas que produire du code : il peut refuser d'en produire, et le dire. Plutôt que d'émettre du C# invalide quand l'attribut est mal posé, sur un type abstrait par exemple, on remonte un diagnostic .

C#
1private static readonly DiagnosticDescriptor MustBeConcrete = new(
2 id: "MYAPP001",
3 title: "Type non instanciable",
4 messageFormat: "'{0}' est abstrait ou statique et ne peut pas être enregistré en DI",
5 category: "DependencyInjection",
6 DiagnosticSeverity.Error,
7 isEnabledByDefault: true);

On l'émet via context.ReportDiagnostic(Diagnostic.Create(MustBeConcrete, location, typeName)) dès qu'on repère le cas. L'erreur apparaît dans l'éditeur, soulignée sous le type fautif, à la même place qu'une erreur du compilateur, et n'atteint jamais l'exécution.

Build-time contre réflexion

L'enregistrement de DI n'est qu'un exemple. La génération de mappers entre DTO et entités, les helpers d'enum (parsing sans boxing, un ToStringFast sans allocation), la sérialisation : partout où l'on écrivait de la réflexion ou du code répétitif à la main, un générateur produit le même résultat à la compilation, une fois, et de façon lisible.

L'écosystème a pris cette direction. System.Text.Json génère ses convertisseurs via un générateur de source, et le logging à haute performance d'ASP.NET s'écrit avec [LoggerMessage] . La raison de fond dépasse la vitesse : le code généré est trimmable et compatible AOT/Native , là où la réflexion fait trébucher l'éditeur de liens. Et une erreur (un service oublié, un type non résolu) surgit à la compilation, pas à la première requête en production.

Le tutoriel officiel couvre toute la surface d'API dans la doc Roslyn , et le document de conception des générateurs incrémentaux détaille le modèle de cache.

Un source generator produit le code que vous auriez écrit à la main, mais c'est le compilateur qui l'écrit et qui le vérifie. La métaprogrammation se joue à la compilation, et l'essentiel du métier est de garder le pipeline comparable par valeur pour que son cache tienne d'une frappe à l'autre.
Stéphane De Todaro — super-dev.app
// Continuer dans .NET
Externaliser le SQL dans les migrations EF Core : procédures et vues en fichiers versionnés
14 août 2026 • 8 min
Orchestrer sans base de données : un découpage ProcessAPI / SystemAPI en .NET
14 août 2026 • 10 min
{{ }}
Interpréter plutôt que compiler : un moteur de templates pour .NET
30 juil. 2026 • 6 min