Die Implementierung eines Source Generators
Als Softwareentwickler muss ich oft etwas tun, das ich eigentlich gar nicht gerne mache: monotone Routinearbeiten, die sich ständig wiederholen und bei denen kein Platz für Kreativität bleibt. Im privaten Alltag lässt sich das nicht vermeiden; irgendjemand muss schließlich regelmäßig die Spülmaschine ausräumen. In meinem Beruf sieht das glücklicherweise anders aus. Hier kann ich mir diese Aufgaben abnehmen lassen und das macht sogar richtig Spaß.
Eine Lösung dafür sind Source Generators. Dabei handelt es sich um Erweiterungen für den C#-Compiler Roslyn, die man selbst schreiben kann oder als NuGet-Pakete ins Projekt einbindet. Während der Kompilierung können sie automatisch zusätzlichen Quellcode erzeugen, und zwar unabhängig von der eingesetzten IDE. Anstatt also immer wieder dieselben Klassen oder Methoden von Hand zu schreiben, lasse ich den Compiler diese Bausteine einfach generieren.
An dieser Stelle könnte ich ausführlich auf die technischen Hintergründe, die Motivation und die möglichen Anwendungsfälle von Source Generators eingehen. Genau das steht hier jedoch nicht im Mittelpunkt. Vielmehr möchte ich zeigen, wie man selbst einen Source Generator baut, welche Stolpersteine dabei auftauchen können und wie man diese am besten überwindet. Als konkretes Beispiel habe ich mich dafür entschieden, die Validierungsmethoden für Propertys einer Klasse anhand festgelegter Attribute automatisch generieren zu lassen. Begleiten Sie mich doch bei der schrittweisen Erstellung unseres ersten Generators.
Aufsetzen des Beispielprojekts
Für unser Beispiel erstellen wir in unserer IDE (ich verwende Visual Studio) eine neue Solution namens SourceGeneratorDemo. Innerhalb der Solution legen wir zwei Projekte an:
- MyApplication – eine Konsolenanwendung, für die wir später unseren Code generieren werden.
- SourceGenerators – eine Klassenbibliothek, die unseren eigentlichen Source Generator enthalten wird.
Im Projekt MyApplication legen wir zunächst eine Klasse Person an. Diese muss als partial deklariert werden, denn ihre Validierungsmethoden sollen später in eine separate Datei generiert werden, aber trotzdem Teil der Klasse Person sein. Die Klasse enthält zwei Eigenschaften, deren Validierungsregeln wir über die Attribute Required und MaxLength festlegen:
- Name – ein Pflichtfeld, das maximal 50 Zeichen enthalten darf.
- Occupation – ein optionales Feld, das auf 100 Zeichen begrenzt ist.
Diese Struktur bildet die Grundlage, auf der unser Source Generator aufbauen wird. Wie Projektstruktur und die Klasse Person aktuell aussehen, sehen Sie auf dem Screenshot:
Abbildung: Projektstruktur zur Implementierung eines Source Generators
Wahl des Source Generators
Bevor wir mit der Implementierung unseres Source Generators starten, lohnt sich ein kurzer Blick auf die beiden verfügbaren Varianten: den klassischen Source Generator und den Incremental Generator. [1]
1. Source Generator
Ein Source Generator (ISourceGenerator) erzeugt bei jedem Build den gesamten Code komplett neu. Unabhängig davon, welche Teile des Projekts sich geändert haben, wird alles erneut verarbeitet. Dieser Ansatz ist einfach umzusetzen, kann jedoch bei größeren Projekten schnell zu längeren Build-Zeiten führen.
2. Incremental Generator
Incremental Generatoren (IIncrementalGenerator) arbeiten differenziert: Sie erkennen, welche Teile des Projekts sich geändert haben, und generieren nur die entsprechenden Code-Bausteine neu. Das spart erheblich Zeit bei Builds, insbesondere in größeren Projekten.
Für unser Beispielprojekt nutzen wir eine Incremental Generator; bei diesem profitieren wir langfristig von kürzeren Build-Zeiten und einer besseren Performance bei einer skalierenden Anwendung.
Implementierung des Source Generators – Grundstruktur
Wir wissen nun, dass wir Validierungsfunktionen generieren möchten und haben uns für die Arbeit mit einem Incremental Generator entschieden. Dann starten wir doch direkt mit der Entwicklung unseres Generators und legen die Basis für unseren automatisch erzeugten Code.
Bevor wir mit der eigentlichen Implementierung beginnen, müssen wir im Projekt SourceGenerators das NuGet-Paket Microsoft.CodeAnalysis.CSharp installieren. Es stellt alle notwendigen Werkzeuge und APIs bereit, die wir für die Erstellung unseres Generators benötigen.
Jetzt legen wir im Projekt SourceGenerators eine Klasse ValidationGenerator an, die für die Generierung unserer Validierungsmethoden zuständig ist. Sie muss das Interface IIncrementalGenerator aus dem Namespace Microsoft.CodeAnalysis implementieren. Damit der Compiler die Klasse tatsächlich als Source Generator behandelt, ist außerdem das Attribut [Generator] notwendig. Erst die Kombination aus Interface und Attribut sorgt dafür, dass unser Generator beim Build ausgeführt wird.
[Generator]
public class ValidationGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
}
}
- Den relevanten Code finden – zum Beispiel alle Eigenschaften einer Klasse.
- Die gefundenen Teile untersuchen – etwa, ob eine Eigenschaft Attribute wie [Required] oder [MaxLength] trägt.
- Den neuen Code registrieren – also die automatisch erzeugten Validierungsmethoden zurück an den Compiler geben.
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var propertyDeclarations = context
.SyntaxProvider.CreateSyntaxProvider(
predicate: (node, _) => node is PropertyDeclarationSyntax, // Filter properties only
transform: (ctx, _) => (Property: (PropertyDeclarationSyntax)ctx.Node, Model: ctx.SemanticModel)
)
.Where(x => x.Property != null);
var allProperties = propertyDeclarations.Collect();
context.RegisterSourceOutput(allProperties, GenerateValidationMethods);
}
Schauen wir uns die einzelnen Schritte an:
1. Nur Eigenschaften betrachten
Mit CreateSyntaxProvider legen wir fest, dass uns nur Property-Deklarationen (also Eigenschaften in Klassen) interessieren. Alles andere im Code – Methoden, Felder oder Events – wird ignoriert.
2. Eigenschaften genauer untersuchen
Danach merken wir uns zu jeder gefundenen Eigenschaft die wichtigen Meta-Informationen, die der Compiler über ein SemanticModel bereitstellt. Dazu gehören zum Beispiel die Attribute wie [Required] oder [MaxLength], die wir später für die Generierung der Validierungsmethoden benötigen.
3. Alle Eigenschaften sammeln
Über Collect() bündeln wir alle gefundenen Properties zu einer einzigen Sammlung. So können wir sie später auf einmal an RegisterSourceOutput übergeben und daraus die Validierungsmethoden generieren, ohne jedes Property einzeln verarbeiten zu müssen.
4. Code erzeugen
Schließlich sagen wir dem Generator: „Bitte nimm diese Liste Propertys und schreibe dafür Quellcode.“ Dafür rufen wir unsere Methode GenerateValidationMethods auf, die später die passenden Validierungsmethoden für jede Property generieren wird.
Damit haben wir die Grundstruktur unseres Source Generators: Eigenschaften im Code finden, die relevanten Informationen daraus gewinnen und anschließend automatisch den passenden Quellcode erzeugen.
Implementierung des Source Generators – Codegenerierung
Nachdem wir die Grundstruktur unseres Source Generators erstellt haben, geht es nun an die eigentliche Codegenerierung. Im Kern funktioniert diese so: Unser Generator erstellt den neuen Code direkt als Text, Zeile für Zeile. Dazu sammeln wir zunächst alle relevanten Eigenschaften, prüfen ihre Attribute und schreiben dann die entsprechenden C#-Methoden in einen String.
Am Ende übergeben wir diesen Text an den Compiler, der daraus eine neue Datei erzeugt, die ins Projekt eingebunden wird, sodass die Methoden wie aus normale C#-Dateien genutzt werden können. Alternativ könnte man den Code auch komplett im Speicher generieren, ohne eine physische Datei anzulegen, der Compiler behandelt ihn trotzdem wie normalen C#-Code.
Ich möchte die generierten Dateien gerne in meiner Solution sehen und daher werden wir pro Klasse eine Datei mit formatiertem Code erzeugen. Die dafür in unserem Generator zuständige Methode GenerateValidationMethods sieht so aus:
private void GenerateValidationMethods(SourceProductionContext spc, ImmutableArray<(PropertyDeclarationSyntax Property, SemanticModel Model)> properties)
{
var validationMethodsToGenerate = CollectValidationMethodsToGenerate(properties);
foreach (var className in validationMethodsToGenerate.Select(v => v.ClassName).Distinct())
{
var validationMethodsToGenerateForClass = validationMethodsToGenerate
.Where(v => v.ClassName == className)
.ToList();
GenerateValidationMethodsForClass(validationMethodsToGenerateForClass, spc);
}
}
Der Ablauf ist folgendermaßen:
1. Zunächst werden alle Properties (mit Meta-Informationen) aller Klassen gesammelt, für die Validierungsmethoden erzeugt werden müssen.
2. Anschließend werden für jede Klasse alle über Attribute definierten Validierungsmethoden erstellt.
Auf diese Weise stellen wir sicher, dass jede Klasse ihre eigenen Validierungen in einer separaten Datei erhält und der generierte Code sauber strukturiert ist.
Schauen wir uns jetzt die aufgerufenen Methoden im Detail an.
private static List<ValidationMethodContext> CollectValidationMethodsToGenerate(ImmutableArray<(PropertyDeclarationSyntax Property, SemanticModel Model)> properties)
{
var validationMethodsToGenerate = new List<ValidationMethodContext>();
foreach (var (Property, Model) in properties)
{
if (!(Model.GetDeclaredSymbol(Property) is IPropertySymbol symbol))
continue;
foreach (var attribute in symbol.GetAttributes())
{
var validationType = GetValidationType(attribute.AttributeClass?.Name);
if (validationType == null)
continue;
var validationMethodContext = new ValidationMethodContext
{
Namespace = symbol.ContainingNamespace.ToDisplayString(),
ClassName = symbol.ContainingType.Name,
Attribute = attribute,
PropertyName = symbol.Name,
};
validationMethodsToGenerate.Add(validationMethodContext);
}
}
return validationMethodsToGenerate;
}
Die Methode CollectValidationMethodsToGenerate durchsucht alle zuvor gesammelten Eigenschaften und verwendet dabei das SemanticModel, um zusätzliche Informationen vom Compiler zu erhalten, also die gesetzten Attribute, den Namespace, den Klassennamen sowie den Namen der Property. Für jede passende Eigenschaft wird ein ValidationMethodContext erzeugt, der diese Daten bündelt und für die spätere Codegenerierung bereitstellt.
Jetzt wird für jede Klasse, die ein zu validierendes Property enthält, die Funktion GenerateValidationMethodsForClass aufgerufen.
private static void GenerateValidationMethodsForClass(List<ValidationMethodContext> validationMethodContexts, SourceProductionContext spc)
{
var className = validationMethodContexts.First().ClassName;
var indent = new string(' ', 4);
var sb = new StringBuilder();
sb.AppendLine($"namespace {validationMethodContexts.First().Namespace}");
sb.AppendLine("{");
sb.AppendLine($" public partial class {className}");
sb.AppendLine(indent + "{");
foreach (var validationMethodContext in validationMethodContexts)
{
switch (GetValidationType(validationMethodContext.Attribute.AttributeClass!.Name))
{
case ValidationType.Required:
AppendRequiredValidation(validationMethodContext.PropertyName, sb, indent + indent);
break;
case ValidationType.MaxLength:
AppendMaxLengthValidation(validationMethodContext.PropertyName, validationMethodContext.Attribute, sb, indent + indent);
break;
}
}
sb.AppendLine(indent + "}");
sb.AppendLine("}");
spc.AddSource(
$"{className}_Validation.g.cs",
SourceText.From(sb.ToString(), Encoding.UTF8)
);
}
private static void AppendRequiredValidation(string propertyName, StringBuilder sb, string indent)
{
sb.AppendLine(indent + $"public void Validate{propertyName}IsSet()");
sb.AppendLine(indent + "{");
sb.AppendLine(indent + $" if (string.IsNullOrWhiteSpace(this.{propertyName}))");
sb.AppendLine(indent + $" throw new System.Exception(\"{propertyName} is required.\");");
sb.AppendLine(indent + "}");
}
private static void AppendMaxLengthValidation(string propertyName, AttributeData attr, StringBuilder sb, string indent)
{
if (attr.ConstructorArguments.Length == 1)
{
var max = attr.ConstructorArguments[0].Value;
sb.AppendLine(indent + $"public void Validate{propertyName}HasValidLength()");
sb.AppendLine(indent + "{");
sb.AppendLine(indent + $" if (this.{propertyName} != null && this.{propertyName}.Length > {max})");
sb.AppendLine(indent + $" throw new System.Exception($\"{propertyName} may contain at most {max} characters.\");");
sb.AppendLine(indent + "}");
}
}
Die Methode GenerateValidationMethodsForClass übernimmt die eigentliche Codegenerierung. Sie bekommt alle zuvor gesammelten ValidationMethodContext-Objekte einer Klasse und erzeugt daraus eine neue Datei mit dem Namen „[Klassenname]_Validation.g.cs“ mit den passenden Validierungsmethoden. Dazu wird ein StringBuilder verwendet, in den Schritt für Schritt der Quellcode geschrieben wird , beginnend mit Namespace und Klassendeklaration. Anschließend durchläuft die Methode alle ValidationMethodContext-Einträge und entscheidet anhand des Attributs, welche Validierungsmethode erzeugt werden soll.
Für [Required] wird eine Methode Validate<PropertyName>IsSet() generiert, die prüft, ob der Wert gesetzt ist. Für [MaxLength] entsteht eine Methode Validate<PropertyName>HasValidLength(), die sicherstellt, dass die maximale Länge nicht überschritten wird. Das funktioniert in diesem Beispiel nur für den Datentyp String. Für eine finale Implementierung müsste der Datentyp der Property natürlich berücksichtigt werden.
Am Ende wird der erzeugte Text mit spc.AddSource() als neue Datei in den Build-Prozess eingespeist. Der Compiler behandelt diese Datei so, als wäre sie von Anfang an Teil des Projekts gewesen.
Damit ist unser Source Generator vollständig: Er sammelt die relevanten Propertys, erkennt deren Attribute und erzeugt die passenden Validierungsmethoden.
Anpassungen an den Projektdateien
Bevor wir den Generator das erste Mal ausführen können, müssen wir zunächst ein paar Einstellungen in den Projektdateien unserer Anwendung vornehmen. Gerade beim Einstieg in die Entwicklung von Source Generatoren treten häufig Stolpersteine auf, zum Beispiel dass der Generator gar nicht ausgeführt wird oder die erzeugten Dateien an unerwarteten Orten landen. Mit den folgenden Anpassungen stellen wir sicher, dass der Generator korrekt eingebunden ist, die vom Compiler erzeugten Dateien an einem festen Ort abgelegt werden und wir sie auch im Projekt sehen können.
Änderungen an MyApplication.csproj
1. Generator als Analyzer einbinden
<ItemGroup>
<ProjectReference Include="..\SourceGenerators\SourceGenerators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
- ProjectReference verweist auf unser Source-Generator-Projekt.
- OutputItemType=“Analyzer“ sorgt dafür, dass der Generator beim Build als Roslyn Analyzer ausgeführt wird.
- ReferenceOutputAssembly=“false“ verhindert, dass die DLL des Generatorprojekts ins Zielprojekt kopiert wird.
2. Compiler-Einstellungen für generierte Dateien
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
- EmitCompilerGeneratedFiles=true sorgt dafür, dass die generierten Dateien vom Compiler sichtbar gemacht werden.
- CompilerGeneratedFilesOutputPath legt den Pfad fest, in den die Dateien geschrieben werden; in unserem Fall den zuvor erstellten Generated-Ordner.
<ItemGroup>
<Compile Remove="Generated\**" />
</ItemGroup>
- Compile Remove=“Generated\**“ führt dazu, dass die im Ordner Generated abgelegten Dateien nicht in die Kompilierung aufgenommen werden. Diese Einstellung ist notwendig, weil der Source Generator den Code bereits zur Compile-Zeit im Speicher bereitstellt. Ohne diesen Ausschluss würde der Code doppelt eingebunden – einmal aus dem Speicher und einmal von der Festplatte – und das Projekt ließe sich nicht kompilieren. Die Dateien im Ordner Generated dienen daher nur zur Einsicht und Fehleranalyse, nicht jedoch für die eigentliche Kompilierung.
Änderungen an SourceGenerators.csproj
1. Target-Framework ändern
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>8.0</LangVersion>
</PropertyGroup>
- TargetFramework = netstandard2.0 stellt sicher, dass der Source Generator in allen Projekten eingebunden werden kann – egal ob .NET-Framework oder moderne .NET-Versionen wie .NET 8.
- LangVersion = 8.0: Da netstandard2.0 standardmäßig nur eine ältere C#-Version mitbringt (meist 7.3), müssen wir die Sprachversion manuell hochsetzen. So können wir moderne Sprachfeatures verwenden, die wir für die Arbeit mit Roslyn und den Source Generator benötigen.
<PropertyGroup>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
- EnforceExtendedAnalyzerRules = true sorgt dafür, dass der Compiler strengere Prüfungen für Analyzer und Source Generatoren durchführt. Konkret bedeutet das: Wenn wir beim Zugriff auf Eigenschaften, Attribute oder Symbole etwas übersehen, wird der Compiler sofort darauf hinweisen. So entdecken wir mögliche Fehler frühzeitig, bevor sie später dazu führen, dass Methoden nicht generiert werden oder Attribute übersehen werden.
Source Generator ausführen
Nachdem wir die Logik zur Codegenerierung implementiert und alle notwendigen Anpassungen an den Projektdateien vorgenommen haben, ist es Zeit, den Generator zum Leben zu erwecken. Source Generators werden immer dann aktiv, wenn der Compiler den Anwendungscode übersetzt. Das bedeutet: Sobald wir unser Projekt bauen, werden alle registrierten Generatoren ausgeführt und erzeugen den zusätzlichen Quellcode.
Wir führen nun unseren Generator das erste Mal aus, in dem wir unsere Solution neu bauen. Die Ergebnisse können wir direkt prüfen:
1. Generator wird ausgeführt
Der Generator wird automatisch beim Build ausgeführt, wir benötigen dazu keine manuellen Schritte. Wir erkennen das unter anderem daran, dass eine neue Datei Person_Validation.g.cs im zuvor konfigurierten Ordner MyApplication\Generated\SourceGenerators\SourceGenerators.ValidationGenerator angelegt wurde.
2. Inhalt der Datei
Öffnen wir die Datei, sehen wir alle Validierungsmethoden, die über Attribute in unserer Klasse definiert wurden. Die generierten Methoden befinden sich im korrekten Namespace und sind Teil der Klasse Person.
namespace MyApplication
{
public partial class Person
{
public void ValidateNameIsSet()
{
if (string.IsNullOrWhiteSpace(this.Name))
throw new System.Exception("Name is required.");
}
public void ValidateNameHasValidLength()
{
if (this.Name != null && this.Name.Length > 50)
throw new System.Exception($"Name may contain at most 50 characters.");
}
public void ValidateOccupationHasValidLength()
{
if (this.Occupation != null && this.Occupation.Length > 100)
throw new System.Exception($"Occupation may contain at most 100 characters.");
}
}
}
3. Kompilierbarkeit
Die generierte Datei ist sofort kompilierbar und kann direkt genutzt werden. Änderungen an den Propertys oder Attributen führen beim nächsten Build automatisch zu aktualisiertem Code.
Damit haben wir bestätigt, dass unser Generator wie gewünscht arbeitet: Er erzeugt den Code korrekt, an der richtigen Stelle, und alle Validierungsmethoden sind vollständig vorhanden.
Debuggen des Source Generators
Beim Entwickeln von Source Generatoren können leicht Fehler auftreten, zum Beispiel Code, der gar nicht oder falsch generiert wird. Solche Probleme sind oft frustrierend und ohne Debugging nur schwer zu lösen. Glücklicherweise lassen sich auch Source Generatoren debuggen. Da sie während der Kompilierung ausgeführt werden, funktioniert klassisches Debugging zwar nicht direkt. Eine einfache und bewährte Möglichkeit ist jedoch, das Debugging direkt im Generator-Code auszulösen. Dazu wird in der Initialize-Methode oder in einer Hilfsmethode des Generators
System.Diagnostics.Debugger.Launch();
eingefügt. Beim nächsten Build fragt Visual Studio automatisch, ob der Debugger angehängt werden soll. Danach können Sie Haltepunkte setzen und den Generator-Code Schritt für Schritt durchgehen, um mögliche Fehlerquellen zu identifizieren.
Fazit
In diesem Beitrag haben wir Schritt für Schritt einen Source Generator in C# erstellt: Wir haben ein Beispielprojekt mit einer Klasse Person aufgesetzt, die Generator-Grundstruktur implementiert und schließlich Methoden zur Validierung von Propertys automatisch erzeugt. Dabei haben wir typische Stolpersteine beim Einstieg in die Generator-Entwicklung behandelt, von den notwendigen Anpassungen in den Projektdateien über die korrekte Einbindung des Generators bis hin zur Generierung der Dateien und Debugging.
Alle über Attribute definierten Validierungsmethoden wurden korrekt in Dateien erzeugt, die sich im Projekt leicht einsehen und kompilieren lassen. Wer möchte, kann das Beispielprojekt nun erweitern: Fügen Sie weitere Klassen mit String-Propertys hinzu – sowohl mit als auch ohne Attribute – und beobachten Sie, wie der Generator automatisch zusätzliche Validierungsdateien und -methoden erstellt.
Ich bin überzeugt, dass Source Generatoren in vielen Projekten eine nützliche Hilfe sein können und sich wiederholende Arbeiten damit erheblich reduzieren lassen. Vielleicht auch in Ihren Projekten?
Hinweise:
[1] Microsoft Dev Blogs: Introducing C# Source Generators
Wollen Sie Source Generatoren nutzen? Dann sprechen Sie mit uns über Ihr Projekt.
Suchen Sie nach einem Team für Ihre Softwareentwicklung oder -modernisierung? Dann laden Sie sich den t2informatik Steckbrief herunter.
Interessieren Sie sich für weitere Tipps aus der Welt der Softwareentwicklung? Testen Sie unseren wöchentlichen Newsletter mit interessanten Beiträgen, Downloads, Empfehlungen und aktuellem Wissen.
Glen Gelmroth hat hier im t2informatik Blog einen weiteren Beiträge veröffentlicht:

Glen Gelmroth
Glen Gelmroth interessiert sich seit seiner Kindheit für Softwareentwicklung und hat sein Hobby zum Beruf gemacht. Seit 2012 ist er Senior Developer bei t2informatik und hat seitdem zahlreiche Projekte – hauptsächlich im .NET-Umfeld – begleitet.
Er glaubt fest daran, dass guter Code so klar sein sollte, dass er auch Jahre später noch verständlich, wartbar und testbar ist. Außerdem begeistert er sich für Jira-Customizing und Automatisierung – denn wer mag schon monotone Aufgaben, wenn man sie clever wegskripten kann?
Im t2informatik Blog veröffentlichen wir Beträge für Menschen in Organisationen. Für diese Menschen entwickeln und modernisieren wir Software. Pragmatisch. ✔️ Persönlich. ✔️ Professionell. ✔️ Ein Klick hier und Sie erfahren mehr.

