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:

Example.cs
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:

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:

Example.cs
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:

Example.cs
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

Example.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
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:

  1. The metadata name in ForAttributeWithMetadataName must be the fully-qualified name including namespace.
  2. If you use RegisterPostInitializationOutput to emit the attribute, it is part of the compilation and should be found.
  3. If the consuming project has a global using that 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.