Development Workflow
Overview
Section titled “Overview”The most difficult part when creating a source generator for the first time is “what to do in what order.” Since each step depends on the results of the previous step, from project setup to packaging, getting the order wrong leads to unnecessary struggles. This section organizes the 7-step development procedure validated while building ObservablePortGenerator and presents it as a standard workflow applicable to any source generator.
Learning Objectives
Section titled “Learning Objectives”Core Learning Objectives
Section titled “Core Learning Objectives”- Understanding source generator project setup
- Required csproj settings and package reference configuration
- Mastering the standard development workflow
- The 7-step sequence and dependencies between steps
- Establishing testing and debugging strategies
- Verification based on Verify snapshot tests and checklists
Development Procedure Overview
Section titled “Development Procedure Overview”Source generator development proceeds in the following 7 steps. This order was naturally formed while implementing ObservablePortGenerator in Part 2, where each step creates the input for the next step in a pipeline structure.
+-------------------------------------------------------------+| Source Generator Development Procedure |+-------------------------------------------------------------+| 1. Project Setup || +-- csproj, package references || || 2. Marker Attribute Definition || +-- [EntityId], [ValueConverter], etc. || || 3. Symbol Analysis Strategy || +-- What information to extract? || || 4. Metadata Class Design || +-- Data structure to hold extracted information || || 5. Code Generation Template Design || +-- Structure of the code to generate || || 6. Unit Test Writing || +-- Verify snapshot tests || || 7. Packaging and Deployment || +-- dotnet pack -c Release |+-------------------------------------------------------------+1. Project Setup
Section titled “1. Project Setup”All source generator development starts with csproj configuration. The netstandard2.0 target and Roslyn package references are settings commonly needed for every generator project.
Basic csproj Configuration
Section titled “Basic csproj Configuration”<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings>
<!-- Source generator required settings --> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> <IsRoslynComponent>true</IsRoslynComponent>
<!-- Package information --> <PackageId>MyCompany.SourceGenerator</PackageId> <Version>1.0.0</Version> <Authors>Your Name</Authors> <Description>Source generator for ...</Description> </PropertyGroup>
<ItemGroup> <!-- Roslyn API --> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" /> <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" /> </ItemGroup>
<ItemGroup> <!-- Package as source generator --> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup>
</Project>Key Configuration Descriptions
Section titled “Key Configuration Descriptions”| Setting | Description |
|---|---|
TargetFramework | netstandard2.0 - maximum compatibility |
EnforceExtendedAnalyzerRules | Enforces analyzer/generator rules |
IsRoslynComponent | Declares this as a Roslyn component |
PrivateAssets="all" | Dependencies do not propagate to consumers |
2. Marker Attribute Definition
Section titled “2. Marker Attribute Definition”Once project setup is complete, define the marker attribute that users will apply to their code. This attribute is used as a signal that the source generator should “process this type.” It follows the same pattern as [ObservablePort] from Part 2.
Generating Attribute via Post-Initialization
Section titled “Generating Attribute via Post-Initialization”[Generator(LanguageNames.CSharp)]public class EntityIdGenerator : IIncrementalGenerator{ // Attribute definition (source code) private const string EntityIdAttribute = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// Apply to types to be generated as Entity Ids. /// </summary> [global::System.AttributeUsage( global::System.AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")] public sealed class EntityIdAttribute : global::System.Attribute; """;
public void Initialize(IncrementalGeneratorInitializationContext context) { // Step 1: Generate attribute (once at compilation start) context.RegisterPostInitializationOutput(ctx => ctx.AddSource( hintName: "EntityIdAttribute.g.cs", sourceText: SourceText.From(EntityIdAttribute, Encoding.UTF8)));
// Step 2: Process types with the attribute // ... }}Why Post-Initialization?
Section titled “Why Post-Initialization?”Compilation Start | v+--------------------------------------+| RegisterPostInitializationOutput | <- Step 1: Generate attribute definition| - EntityIdAttribute.g.cs generated |+--------------------------------------+ | v+--------------------------------------+| User code parsing | <- User can use [EntityId]| - [EntityId] attribute recognized |+--------------------------------------+ | v+--------------------------------------+| ForAttributeWithMetadataName | <- Step 2: Generate code for attribute targets| - EntityId type generated |+--------------------------------------+ | vCompilation Complete3. Symbol Analysis Strategy
Section titled “3. Symbol Analysis Strategy”Once the marker attribute is ready, decide what information to extract from types annotated with it. The decisions at this step determine the shape of the Step 4 metadata class, so all information needed for generated code must be identified without omission.
Determining Information to Analyze
Section titled “Determining Information to Analyze”// Example: information needed for Entity Id generator// Input:[EntityId]public readonly partial record struct ProductId;
// Information to extract:// - Type name: ProductId// - Namespace: MyApp.Domain// - Modifiers: readonly partial record struct// - Existing interfaces: noneUsing ForAttributeWithMetadataName
Section titled “Using ForAttributeWithMetadataName”public void Initialize(IncrementalGeneratorInitializationContext context){ // Post-initialization...
var provider = context.SyntaxProvider .ForAttributeWithMetadataName( fullyQualifiedMetadataName: "MyCompany.SourceGenerator.EntityIdAttribute", predicate: static (node, _) => node is StructDeclarationSyntax, transform: static (ctx, _) => MapToEntityIdInfo(ctx)) .Where(static x => x is not null);
context.RegisterSourceOutput(provider, Execute);}
private static EntityIdInfo? MapToEntityIdInfo( GeneratorAttributeSyntaxContext context){ if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) return null;
return new EntityIdInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), IsReadOnly: typeSymbol.IsReadOnly);}4. Metadata Class Design
Section titled “4. Metadata Class Design”Design the data structure to hold the information determined in Step 3. Since this metadata class is used for equality comparison in Roslyn’s incremental caching, it must be defined as a record that guarantees value equality.
Defining Immutable Data Structures with record
Section titled “Defining Immutable Data Structures with record”// Data class holding extracted informationpublic sealed record EntityIdInfo( string TypeName, string Namespace, bool IsReadOnly);
// For complex cases, use nested recordspublic sealed record ValidationInfo( string TypeName, string Namespace, IReadOnlyList<PropertyValidation> Properties);
public sealed record PropertyValidation( string PropertyName, string PropertyType, IReadOnlyList<ValidationRule> Rules);
public sealed record ValidationRule( string RuleName, IReadOnlyDictionary<string, object?> Arguments);Why record?
Section titled “Why record?”// ✅ record advantages// 1. Immutability - essential for incremental caching// 2. Value equality - Equals/GetHashCode auto-generated// 3. Conciseness - less code
public sealed record EntityIdInfo(string TypeName, string Namespace);
// ❌ Implementing with class (verbose)public sealed class EntityIdInfo : IEquatable<EntityIdInfo>{ public string TypeName { get; } public string Namespace { get; }
public EntityIdInfo(string typeName, string @namespace) { TypeName = typeName; Namespace = @namespace; }
public bool Equals(EntityIdInfo? other) { /* ... */ } public override bool Equals(object? obj) { /* ... */ } public override int GetHashCode() { /* ... */ }}5. Code Generation Template Design
Section titled “5. Code Generation Template Design”Once metadata is ready, determine the shape of the code to actually generate. First define the goal of “what the finished code should look like,” then implement it by assembling with StringBuilder.
Determining the Code Structure to Generate
Section titled “Determining the Code Structure to Generate”// Goal: this code should be generated[DebuggerDisplay("{ToString()}")]public readonly partial record struct ProductId : IEntityId<ProductId>, IComparable<ProductId>{ public Ulid Value { get; }
private ProductId(Ulid value) => Value = value;
public static ProductId New() => new(Ulid.NewUlid()); public static ProductId Create(Ulid value) => new(value); public static ProductId Empty => new(Ulid.Empty);
public int CompareTo(ProductId other) => Value.CompareTo(other.Value);
public static bool operator >(ProductId left, ProductId right) => left.CompareTo(right) > 0; // ...
public override string ToString() => Value.ToString();}Generating with StringBuilder
Section titled “Generating with StringBuilder”private static string GenerateEntityIdSource(EntityIdInfo info){ var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>"); sb.AppendLine("#nullable enable"); sb.AppendLine(); sb.AppendLine("using System;"); sb.AppendLine("using System.Diagnostics;"); sb.AppendLine(); sb.AppendLine($"namespace {info.Namespace};"); sb.AppendLine(); sb.AppendLine($"[DebuggerDisplay(\"{{ToString()}}\")]"); sb.AppendLine($"public readonly partial record struct {info.TypeName} : IEntityId<{info.TypeName}>, IComparable<{info.TypeName}>"); sb.AppendLine("{"); sb.AppendLine(" public Ulid Value { get; }"); sb.AppendLine(); sb.AppendLine($" private {info.TypeName}(Ulid value) => Value = value;"); sb.AppendLine(); sb.AppendLine($" public static {info.TypeName} New() => new(Ulid.NewUlid());"); sb.AppendLine($" public static {info.TypeName} Create(Ulid value) => new(value);"); sb.AppendLine($" public static {info.TypeName} Empty => new(Ulid.Empty);"); // ... sb.AppendLine("}");
return sb.ToString();}6. Unit Test Writing
Section titled “6. Unit Test Writing”Once code generation logic is implemented, verify the generation results with Verify snapshot tests. Since the accuracy of generated code is difficult to confirm manually, fixing expected results with snapshots is essential.
Test Project Setup
Section titled “Test Project Setup”<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <TargetFramework>net9.0</TargetFramework> <IsPackable>false</IsPackable> </PropertyGroup>
<ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" /> <PackageReference Include="xunit" Version="2.9.0" /> <PackageReference Include="Verify.Xunit" Version="26.6.0" /> <PackageReference Include="Shouldly" Version="4.2.1" /> </ItemGroup>
<ItemGroup> <ProjectReference Include="..\MyCompany.SourceGenerator\MyCompany.SourceGenerator.csproj" /> </ItemGroup>
</Project>Verify Snapshot Tests
Section titled “Verify Snapshot Tests”public sealed class EntityIdGeneratorTests{ private readonly EntityIdGenerator _sut = new();
[Fact] public Task EntityIdGenerator_ShouldGenerate_BasicEntityId() { // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[EntityId] public readonly partial record struct ProductId; """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual); }}Test Runner Implementation
Section titled “Test Runner Implementation”public static class SourceGeneratorTestRunner{ private static readonly Type[] RequiredTypes = [ typeof(object), // System.Runtime ];
public static string? Generate<TGenerator>( this TGenerator generator, string sourceCode) where TGenerator : IIncrementalGenerator, new() { // 1. Create syntax tree var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
// 2. Collect reference assemblies var references = RequiredTypes .Select(t => t.Assembly.Location) .Distinct() .Select(loc => MetadataReference.CreateFromFile(loc)) .ToList();
// 3. Create compilation var compilation = CSharpCompilation.Create( assemblyName: "TestAssembly", syntaxTrees: [syntaxTree], references: references, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
// 4. Run generator var driver = CSharpGeneratorDriver.Create(generator); driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation( compilation, out var outputCompilation, out var diagnostics);
// 5. Verify diagnostics var errors = outputCompilation.GetDiagnostics() .Where(d => d.Severity == DiagnosticSeverity.Error) .ToList();
errors.ShouldBeEmpty();
// 6. Return generated code var result = driver.GetRunResult(); return result.GeneratedTrees .Select(t => t.GetText().ToString()) .LastOrDefault(); // Last generated file (excluding attribute) }}7. Packaging and Deployment
Section titled “7. Packaging and Deployment”Once tests pass, package the source generator as a NuGet package for deployment. Since source generators must include the DLL in the analyzers/dotnet/cs path unlike regular libraries, the packaging configuration already set in csproj takes effect here.
NuGet Package Creation
Section titled “NuGet Package Creation”# Build with Release configuration (important!)dotnet pack -c Release -o ./packages
# Add to local feeddotnet nuget push ./packages/MyCompany.SourceGenerator.1.0.0.nupkg \ --source ./local-feedUsage in Consumer Projects
Section titled “Usage in Consumer Projects”<ItemGroup> <!-- Reference as analyzer/generator --> <PackageReference Include="MyCompany.SourceGenerator" Version="1.0.0" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /></ItemGroup>Development Checklist
Section titled “Development Checklist”## Source Generator Development Checklist
### Project Setup- [ ] TargetFramework: netstandard2.0- [ ] EnforceExtendedAnalyzerRules: true- [ ] IsRoslynComponent: true- [ ] Microsoft.CodeAnalysis.CSharp reference
### Implementation- [ ] IIncrementalGenerator implementation- [ ] [Generator] attribute applied- [ ] Marker attribute generated via Post-Initialization- [ ] Target filtering with ForAttributeWithMetadataName- [ ] Metadata record class defined- [ ] Code generation template written
### Testing- [ ] Verify snapshot tests written- [ ] Boundary case tests added- [ ] Error case tests added
### Quality- [ ] // <auto-generated/> header in generated code- [ ] #nullable enable included- [ ] ExcludeFromCodeCoverage attribute- [ ] Deterministic output (same input -> same output)
### Deployment- [ ] dotnet pack -c Release- [ ] Version number updated- [ ] CHANGELOG writtenSummary at a Glance
Section titled “Summary at a Glance”Here is a summary of the 7-step standard procedure for source generator development.
| Step | Key Work |
|---|---|
| 1. Project setup | netstandard2.0, Roslyn package references |
| 2. Attribute definition | Generate marker attribute via Post-Initialization |
| 3. Symbol analysis | Target filtering with ForAttributeWithMetadataName |
| 4. Metadata | Define immutable data structure with record |
| 5. Code generation | StringBuilder + Raw String Literals |
| 6. Testing | Verify snapshot tests |
| 7. Deployment | dotnet pack -c Release |
These 7 steps are a standard procedure that applies equally to all source generators including ObservablePortGenerator. Starting from the next section, we will implement three practical generators following this procedure to confirm the specific implementation methods for each step.
Q1: Why must the source generator project’s TargetFramework be netstandard2.0?
Section titled “Q1: Why must the source generator project’s TargetFramework be netstandard2.0?”A: Source generators run within the Roslyn compiler process, and the compiler only loads netstandard2.0 assemblies. Building with net8.0 or net9.0 prevents the compiler from loading the generator assembly, causing the “generator doesn’t run” phenomenon.
Q2: What is the difference between generating the marker attribute with RegisterPostInitializationOutput versus deploying it as a separate NuGet package?
Section titled “Q2: What is the difference between generating the marker attribute with RegisterPostInitializationOutput versus deploying it as a separate NuGet package?”A: The Post-Initialization approach provides both the attribute and generated code with a single source generator, keeping dependencies simple. In contrast, separating into a separate package allows using the attribute without the generator dependency in projects that only reference the attribute (such as interface projects). For cases like ObservablePortGenerator used within a single project, Post-Initialization is more convenient.
Q3: Which step in the 7-step workflow takes the most time?
Section titled “Q3: Which step in the 7-step workflow takes the most time?”A: Step 3 (symbol analysis strategy) and Step 5 (code generation template design) require the most time. Deciding what information to extract forms the foundation for all subsequent steps, and establishing the target shape of the code to generate first makes implementation smoother. In contrast, Step 1 (project setup) and Step 7 (packaging) can be completed quickly by reusing templates.
We will now apply the 7-step workflow organized in this section in practice. The next section implements the most commonly needed strong-typed Entity Id generator in DDD.