Custom Source Generators
Problem
Your team has domain-specific boilerplate patterns that repeat across modules. For example, every aggregate root needs a factory method, every repository interface follows the same shape, or every integration event needs a corresponding handler stub. You want to automate these patterns the same way Modulus automates handler registration and strongly typed IDs.
Solution
Create a custom Roslyn incremental source generator that follows the same patterns used by the Modulus generators. The generator runs at compile time, discovers annotated types, and produces the boilerplate code automatically.
Step 1: Create the Generator Project
Create a new class library targeting netstandard2.0:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
</ItemGroup>
</Project>Target framework
Source generators must target netstandard2.0. This is a Roslyn requirement -- generators run inside the compiler process, which loads netstandard2.0 assemblies.
Step 2: Define a Marker Attribute
Create an attribute in your abstractions project that users will apply to trigger generation:
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GenerateRepositoryAttribute : Attribute { }Step 3: Implement the Generator
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
[Generator]
public class RepositoryGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var provider = context.SyntaxProvider.ForAttributeWithMetadataName(
"YourNamespace.GenerateRepositoryAttribute",
predicate: (node, _) => node is ClassDeclarationSyntax,
transform: (ctx, _) => GetModel(ctx));
context.RegisterSourceOutput(provider, GenerateSource);
}
private static EntityModel GetModel(GeneratorAttributeSyntaxContext context)
{
var symbol = (INamedTypeSymbol)context.TargetSymbol;
return new EntityModel(
symbol.Name,
symbol.ContainingNamespace.ToDisplayString());
}
private static void GenerateSource(
SourceProductionContext context, EntityModel model)
{
var source = $$"""
namespace {{model.Namespace}};
public interface I{{model.Name}}Repository
{
Task<{{model.Name}}?> GetByIdAsync(
{{model.Name}}Id id, CancellationToken ct = default);
Task AddAsync(
{{model.Name}} entity, CancellationToken ct = default);
}
""";
context.AddSource($"I{model.Name}Repository.g.cs", source);
}
private record EntityModel(string Name, string Namespace);
}Step 4: Reference the Generator
In consuming projects, reference your generator as an analyzer:
<ProjectReference Include="..\YourGenerators\YourGenerators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />Step 5: Use It
[GenerateRepository]
public class Product : AggregateRoot<ProductId>
{
// ...
}
// Generated: IProductRepository with GetByIdAsync and AddAsyncTips
- Use
ForAttributeWithMetadataNamefor attribute-triggered generators -- it is the most efficient filtering API - Keep generator logic pure -- extract metadata into an
Equatablemodel, then generate from the model. This enables the incremental cache to skip re-generation when inputs haven't changed - Test with
CSharpGeneratorDriver-- seetests/Modulus.Generators.Tests/GeneratorTestHelper.csfor a working test harness - Reference the Modulus generators as examples:
StronglyTypedIdGenerator,HandlerRegistrationGenerator, andModuleRegistrationGenerator
See Also
- Source Generators Overview -- How Modulus generators work
- Microsoft: Incremental Generators -- Official Roslyn documentation