Skip to content

Development Workflow

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.

  1. Understanding source generator project setup
    • Required csproj settings and package reference configuration
  2. Mastering the standard development workflow
    • The 7-step sequence and dependencies between steps
  3. Establishing testing and debugging strategies
    • Verification based on Verify snapshot tests and checklists

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 |
+-------------------------------------------------------------+

All source generator development starts with csproj configuration. The netstandard2.0 target and Roslyn package references are settings commonly needed for every generator project.

<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>
SettingDescription
TargetFrameworknetstandard2.0 - maximum compatibility
EnforceExtendedAnalyzerRulesEnforces analyzer/generator rules
IsRoslynComponentDeclares this as a Roslyn component
PrivateAssets="all"Dependencies do not propagate to consumers

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
// ...
}
}
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 |
+--------------------------------------+
|
v
Compilation Complete

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.

// 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: none
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);
}

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 information
public sealed record EntityIdInfo(
string TypeName,
string Namespace,
bool IsReadOnly);
// For complex cases, use nested records
public 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);
// ✅ 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() { /* ... */ }
}

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();
}
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();
}

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.

<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>
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);
}
}
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)
}
}

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.

Terminal window
# Build with Release configuration (important!)
dotnet pack -c Release -o ./packages
# Add to local feed
dotnet nuget push ./packages/MyCompany.SourceGenerator.1.0.0.nupkg \
--source ./local-feed
<ItemGroup>
<!-- Reference as analyzer/generator -->
<PackageReference Include="MyCompany.SourceGenerator" Version="1.0.0"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>

## 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 written

Here is a summary of the 7-step standard procedure for source generator development.

StepKey Work
1. Project setupnetstandard2.0, Roslyn package references
2. Attribute definitionGenerate marker attribute via Post-Initialization
3. Symbol analysisTarget filtering with ForAttributeWithMetadataName
4. MetadataDefine immutable data structure with record
5. Code generationStringBuilder + Raw String Literals
6. TestingVerify snapshot tests
7. Deploymentdotnet 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.

-> 02. Entity Id Generator