Attribute-Driven Source Generation Patterns
The most common pattern for source generators is attribute-driven generation: the developer decorates a type with an attribute, and the generator produces code based on that type. This article covers the patterns, pitfalls, and best practices for this approach.
Emitting the Marker Attribute
Your generator should emit its own marker attribute via RegisterPostInitializationOutput. This ensures the attribute is available in the compilation without requiring a separate runtime package:
context.RegisterPostInitializationOutput(ctx =>
{
ctx.AddSource("MapperAttribute.g.cs", """
namespace AutoMapper.Generated;
[System.AttributeUsage(
System.AttributeTargets.Class,
AllowMultiple = false,
Inherited = false)]
public sealed class GenerateMapperAttribute : System.Attribute
{
public GenerateMapperAttribute(System.Type target)
{
Target = target;
}
public System.Type Target { get; }
public bool IgnoreCase { get; set; }
}
""");
});
A few design points:
- Use
sealed— there is no reason to inherit from a marker attribute. - Set
Inherited = false— derived classes should opt in explicitly. - Place it in a namespace that signals it is generated.
Finding Attributed Types Efficiently
Use ForAttributeWithMetadataName for the best performance. It uses Roslyn's internal metadata indices to find attributed types without scanning every syntax node:
var pipeline = context.SyntaxProvider
.ForAttributeWithMetadataName(
"AutoMapper.Generated.GenerateMapperAttribute",
predicate: (node, _) => node is ClassDeclarationSyntax,
transform: (ctx, ct) => ExtractModel(ctx));
The fully-qualified metadata name must match exactly, including the namespace. If it does not match, the generator silently produces nothing — a common source of confusion.
Extracting Attribute Arguments
In the transform step, you have access to the semantic model. Use it to read attribute constructor arguments and named properties:
private static MapperModel ExtractModel(
GeneratorAttributeSyntaxContext ctx)
{
var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
var attribute = ctx.Attributes.Single();
// Constructor argument: Target type
var targetType = (INamedTypeSymbol)attribute
.ConstructorArguments[0].Value!;
// Named argument: IgnoreCase
var ignoreCase = attribute.NamedArguments
.FirstOrDefault(a => a.Key == "IgnoreCase")
.Value.Value is true;
var sourceProps = symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => !p.IsStatic && p.DeclaredAccessibility
== Accessibility.Public)
.Select(p => new PropModel(
p.Name, p.Type.ToDisplayString()))
.ToArray();
var targetProps = targetType.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => !p.IsStatic && p.DeclaredAccessibility
== Accessibility.Public)
.Select(p => new PropModel(
p.Name, p.Type.ToDisplayString()))
.ToArray();
return new MapperModel(
symbol.ContainingNamespace.ToDisplayString(),
symbol.Name,
targetType.ToDisplayString(),
sourceProps,
targetProps,
ignoreCase);
}
private record PropModel(string Name, string TypeName);
private record MapperModel(
string Namespace,
string SourceName,
string TargetFullName,
PropModel[] SourceProperties,
PropModel[] TargetProperties,
bool IgnoreCase);
Generating the Output
context.RegisterSourceOutput(pipeline, (ctx, model) =>
{
var comparator = model.IgnoreCase
? "StringComparer.OrdinalIgnoreCase"
: "StringComparer.Ordinal";
var assignments = model.TargetProperties
.Select(tp =>
{
var match = model.SourceProperties.FirstOrDefault(sp =>
string.Equals(sp.Name, tp.Name,
model.IgnoreCase
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal));
return match is not null
? $" {tp.Name} = source.{match.Name},"
: $" // No matching property for {tp.Name}";
});
var body = string.Join("\n", assignments);
ctx.AddSource($"{model.SourceName}.Mapper.g.cs", $$"""
namespace {{model.Namespace}};
partial class {{model.SourceName}}
{
public {{model.TargetFullName}} MapTo()
{
var source = this;
return new {{model.TargetFullName}}
{
{{body}}
};
}
}
""");
});
Handling Edge Cases
Nested Classes
If the attributed class is nested, you need to wrap the generated code in the parent type:
if (symbol.ContainingType is not null)
{
// Emit: partial class Outer { partial class Inner { ... } }
}
Generic Types
Generic types need special handling. The generated partial declaration must include the same type parameters:
var typeParams = symbol.TypeParameters.Length > 0
? $"<{string.Join(", ", symbol.TypeParameters.Select(t => t.Name))}>"
: "";
// partial class MyClass<T> { ... }
Multiple Attributes
If your attribute has AllowMultiple = true, the ctx.Attributes collection may contain multiple instances. Process all of them:
transform: (ctx, ct) =>
ctx.Attributes.Select(attr => ExtractModel(ctx, attr)).ToArray()
The "Attribute Not Found" Problem
The most common issue with attribute-driven generation is the attribute not being found. Check these things:
- The metadata name in
ForAttributeWithMetadataNamemust be the fully-qualified name including namespace. - If you use
RegisterPostInitializationOutputto emit the attribute, it is part of the compilation and should be found. - If the consuming project has a
global usingthat aliases the namespace, the metadata name might differ.
Summary
Attribute-driven generation is the bread and butter of source generators. The pattern is straightforward: emit a marker attribute, find types decorated with it using ForAttributeWithMetadataName, extract the data you need into value-type models, and generate the output. Handle edge cases like nested and generic types, and always extract only the data you need to keep the pipeline cacheable.