End-to-End Walkthrough¶
Trace one real feature — StrongId — from the user's single-line declaration through all five layers of a Roslyn toolkit: attribute, projection, generator, analyzer, and code fix. By the end you'll see how each layer has a clear responsibility and how they compose into a seamless developer experience.
The source code is from Deepstaging and the Samples repository.
What the User Writes¶
A developer declares a strongly-typed ID by applying [StrongId] to a partial struct:
[StrongId(Converters = IdConverters.EfCoreValueConverter)]
public readonly partial struct WorkshopId;
[StrongId(Converters = IdConverters.EfCoreValueConverter)]
public readonly partial struct SessionId;
[StrongId(Converters = IdConverters.EfCoreValueConverter)]
public readonly partial struct AttendeeId;
That's all the user writes. The generator produces the full implementation: constructor, Value property, IEquatable<T>, IComparable<T>, IParsable<T>, ToString(), factory methods, and an EF Core ValueConverter.
Layer 1: Attributes¶
The user-facing package contains only the attribute and supporting enums. No Roslyn dependencies.
[AttributeUsage(AttributeTargets.Struct)]
public sealed class StrongIdAttribute : Attribute
{
public BackingType BackingType { get; set; } = BackingType.Guid;
public IdConverters Converters { get; set; } = IdConverters.None;
}
[Flags]
public enum IdConverters
{
None = 0,
JsonConverter = 1 << 0,
EfCoreValueConverter = 1 << 1,
}
Layer 2: Projection¶
The Projection layer extracts attribute data and builds a pipeline-safe model.
AttributeQuery¶
public sealed record StrongIdAttributeQuery(AttributeData AttributeData)
: AttributeQuery(AttributeData)
{
public BackingType BackingType =>
NamedArg<int>(nameof(StrongIdAttribute.BackingType))
.ToEnum<BackingType>()
.OrDefault(BackingType.Guid);
public ValidSymbol<INamedTypeSymbol> BackingTypeSymbol(SemanticModel model) =>
BackingType switch
{
BackingType.Guid => model.WellKnownSymbols.Guid,
BackingType.Int => model.WellKnownSymbols.Int32,
BackingType.Long => model.WellKnownSymbols.Int64,
BackingType.String => model.WellKnownSymbols.String,
_ => throw new ArgumentOutOfRangeException(nameof(BackingType))
};
public IdConverters Converters =>
NamedArg<int>(nameof(StrongIdAttribute.Converters))
.ToEnum<IdConverters>()
.OrDefault(IdConverters.None);
}
Model¶
[PipelineModel]
public sealed record StrongIdModel
{
public required string Namespace { get; init; }
public required string TypeName { get; init; }
public required string Accessibility { get; init; }
public required BackingType BackingType { get; init; }
public required IdConverters Converters { get; init; }
public required TypeSnapshot BackingTypeSnapshot { get; init; }
}
Query¶
extension(ValidSymbol<INamedTypeSymbol> symbol)
{
public StrongIdModel ToStrongIdModel(SemanticModel model) =>
symbol.GetAttribute<StrongIdAttribute>()
.Map(attr => attr.AsQuery<StrongIdAttributeQuery>())
.Map(attr => new StrongIdModel
{
Namespace = symbol.Namespace ?? "",
TypeName = symbol.Name,
Accessibility = symbol.AccessibilityString,
BackingType = attr.BackingType,
BackingTypeSnapshot = attr.BackingTypeSymbol(model).ToSnapshot(),
Converters = attr.Converters
})
.OrThrow($"Expected '{symbol.FullyQualifiedName}' to have StrongIdAttribute.");
}
Layer 3: Generator + Writer¶
The generator is thin — 14 lines of wiring:
[Generator]
public sealed class StrongIdGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var models = context.ForAttribute<StrongIdAttribute>()
.Map(static (ctx, _) => ctx.TargetSymbol
.AsValidNamedType()
.ToStrongIdModel(ctx.SemanticModel));
context.RegisterSourceOutput(models, static (ctx, model) =>
{
model.WriteStrongId()
.AddSourceTo(ctx, HintName.From(model.Namespace, model.TypeName));
});
}
}
The writer does the heavy lifting:
extension(StrongIdModel model)
{
public OptionalEmit WriteStrongId()
{
var backingType = model.BackingTypeSnapshot;
var valueProperty = PropertyBuilder
.Parse($"public {backingType.FullyQualifiedName} Value {{ get; }}");
return TypeBuilder
.Parse($"{model.Accessibility} partial struct {model.TypeName}")
.InNamespace(model.Namespace)
.AddProperty(valueProperty)
.AddConstructor(model)
.ImplementsIEquatable(backingType, valueProperty)
.ImplementsIComparable(backingType, valueProperty)
.ImplementsIParsable(backingType)
.OverridesToString(
model.BackingType == BackingType.String
? $"{valueProperty.Name} ?? \"\""
: $"{valueProperty.Name}.ToString()",
true)
.AddFactoryMethods(model)
.AddConverters(model, valueProperty)
.Emit();
}
}
Layer 4: Analyzers¶
Analyzers enforce correctness at compile time. StrongId has two:
// The struct must be partial (so the generator can extend it)
[Reports(Diagnostics.StrongIdMustBePartial)]
public sealed class StrongIdMustBePartialAnalyzer : TypeAnalyzer<StrongIdAttribute>
{
protected override bool ShouldReport(ValidSymbol<INamedTypeSymbol> symbol) =>
!symbol.IsPartial();
}
// The struct should be readonly (value semantics)
[Reports(Diagnostics.StrongIdShouldBeReadonly)]
public sealed class StrongIdShouldBeReadonlyAnalyzer : TypeAnalyzer<StrongIdAttribute>
{
protected override bool ShouldReport(ValidSymbol<INamedTypeSymbol> symbol) =>
!symbol.IsReadonly();
}
Each analyzer is a single class, a single override, returning a boolean. The base class handles all Roslyn registration boilerplate.
Layer 5: Code Fixes¶
Code fixes pair with analyzers to offer one-click repairs:
[CodeFix(Diagnostics.StrongIdMustBePartial)]
[ExportCodeFixProvider(LanguageNames.CSharp)]
public sealed class StructMustBePartialCodeFix : StructCodeFix
{
protected override CodeAction CreateFix(
Document document,
ValidSyntax<StructDeclarationSyntax> syntax) =>
document.AddPartialModifierAction(syntax);
}
[CodeFix(Diagnostics.StrongIdShouldBeReadonly)]
[ExportCodeFixProvider(LanguageNames.CSharp)]
public sealed class StructShouldBeReadonlyCodeFix : StructCodeFix
{
protected override CodeAction CreateFix(
Document document,
ValidSyntax<StructDeclarationSyntax> syntax) =>
document.AddModifierAction(syntax, SyntaxKind.ReadOnlyKeyword, "Add 'readonly' modifier");
}
The Result¶
From the user's perspective, they write:
[StrongId(Converters = IdConverters.EfCoreValueConverter)]
public readonly partial struct WorkshopId;
And get:
- ✅ A complete struct with
Valueproperty and constructor - ✅
IEquatable<WorkshopId>,IComparable<WorkshopId>,IParsable<WorkshopId> - ✅
ToString(),Parse(),TryParse(), factory methods - ✅ An EF Core
ValueConverter<WorkshopId, Guid>for database mapping - ✅ Compile-time errors if
partialorreadonlyis missing — with one-click fixes
Key Takeaways¶
- Attributes are minimal — just data containers, no Roslyn dependency
- Projection is the single source of truth — both generators and analyzers consume the same queries and models
- Generators are thin — they wire pipelines, writers do the work
- Analyzers are single-purpose — one class, one rule, one boolean
- Code fixes pair with analyzers — same diagnostic ID, one-click resolution
- The user experience is seamless — declare intent with an attribute, get a complete implementation with compile-time safety