The Projection Pattern¶
The Projection layer is the single source of truth for interpreting attributes. Both generators and analyzers consume the same queries and models — never interpret an attribute twice. If you skip this layer, you'll end up with generators and analyzers that disagree on what an attribute means.
Three components compose the pattern: AttributeQuery types for safe attribute access, Models for pipeline-safe data, and Query extensions that bridge symbols to models. All examples are drawn from the Deepstaging source generator suite.
Overview¶
The pattern flows in one direction:
| Component | Role | Location |
|---|---|---|
| AttributeQuery | Wraps AttributeData with typed properties and defaults |
Projection/Domain/Attributes/ |
| Model | A [PipelineModel] record capturing everything for generation |
Projection/Domain/Models/ |
| Query | Extension methods that chain queries into models | Projection/Domain/Queries.cs |
1. AttributeQuery Types¶
An AttributeQuery wraps AttributeData and exposes typed properties with safe defaults. Here's the real StrongIdAttributeQuery:
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);
}
Key patterns:
NamedArg<T>(name)— reads a named attribute argument with type safetyConstructorArg<T>(index)— reads a positional constructor argument.OrDefault(value)— provides a fallback when the argument is missing.OrNull()— returns null for truly optional values.OrThrow(message)— fails explicitly for required values.ToEnum<T>()— converts int arguments to enum types (Roslyn stores enums as ints)
More AttributeQuery Examples¶
A query that reads a type argument from a generic attribute:
public sealed record HttpClientAttributeQuery(AttributeData AttributeData)
: AttributeQuery(AttributeData)
{
public OptionalSymbol<INamedTypeSymbol> ConfigurationType =>
AttributeData.Query()
.GetTypeArgument(0)
.Map(symbol => symbol.AsNamedType())
.OrDefault(OptionalSymbol<INamedTypeSymbol>.Empty);
public string? BaseAddress => NamedArg<string>("BaseAddress").OrNull();
}
A query that resolves referenced types:
public sealed record UsesAttributeQuery(AttributeData AttributeData)
: AttributeQuery(AttributeData)
{
public ValidSymbol<INamedTypeSymbol> ModuleType =>
ConstructorArg<INamedTypeSymbol>(0)
.Map(symbol => symbol.AsValidNamedType())
.OrThrow("UsesAttribute must have a valid module type.");
public ImmutableArray<EffectsModuleModel> EffectsModules =>
ModuleType.QueryEffectsModules();
}
2. Models¶
Models are [PipelineModel] records that capture everything needed for generation. They use required properties and EquatableArray<T> for correct incremental caching.
[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; }
}
When to use snapshots vs. strings
Use snapshot types (TypeSnapshot, MethodSnapshot) when your writer needs multiple properties from a symbol — they capture everything in one call. Use plain strings when you only need a name or type reference.
Nested Models¶
Complex features use nested models:
[PipelineModel]
public sealed record ConfigModel
{
public required string Namespace { get; init; }
public required TypeRef TypeName { get; init; }
public required string Accessibility { get; init; }
public required string Section { get; init; }
public EquatableArray<ConfigTypeModel> ExposedConfigurationTypes { get; init; } = [];
public bool HasSecrets => ExposedConfigurationTypes.Any(ct => ct.Properties.Any(p => p.IsSecret));
}
Model Rules¶
- Always use
[PipelineModel]— it generates equality members for incremental caching - Use
EquatableArray<T>instead ofImmutableArray<T>for collections - Use snapshot types instead of
ISymbolfor symbol data — symbols are not safe across pipeline stages - Use
requiredon all properties that must be set
3. Query Extensions¶
Query extensions are the glue — they chain from symbols to attribute queries to models. Here's the real StrongId 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.");
}
Querying Multiple Attributes¶
When a symbol can have multiple instances of the same attribute:
extension(ValidSymbol<INamedTypeSymbol> symbol)
{
public ImmutableArray<EffectsModuleAttributeQuery> EffectsModuleAttributes() =>
[
..symbol.GetAttributes<EffectsModuleAttribute>()
.Select(attr => attr.AsQuery<EffectsModuleAttributeQuery>())
];
}
Querying Methods and Parameters¶
Queries compose across symbol types — from types to methods to parameters:
extension(ValidSymbol<INamedTypeSymbol> symbol)
{
public HttpClientModel? QueryHttpClient() =>
symbol.GetAttribute<HttpClientAttribute>()
.OrElse(() => symbol.GetAttribute(typeof(HttpClientAttribute<>)))
.Map(attr => attr.AsQuery<HttpClientAttributeQuery>())
.Map(query => new HttpClientModel
{
Namespace = symbol.Namespace ?? "",
TypeName = symbol.Name,
Accessibility = symbol.AccessibilityString,
ConfigurationType = query.ConfigurationType.FullyQualifiedName,
BaseAddress = query.BaseAddress,
Requests =
[
..symbol.QueryMethods()
.ThatArePartialDefinitions()
.Select(method => method.QueryHttpRequest())
.Where(model => model.HasValue)
.Select(model => model.Value)
]
})
.OrNull();
}
Fluent Query Chains¶
Compose symbol queries naturally:
// Good: fluent chain
var methods = type.QueryMethods()
.ThatArePublic()
.ThatAreNotStatic()
.WithAttribute<EffectAttribute>()
.GetAll();
// Avoid: manual LINQ over raw Roslyn APIs
var methods = type.GetMembers()
.OfType<IMethodSymbol>()
.Where(m => m.DeclaredAccessibility == Accessibility.Public)
.Where(m => !m.IsStatic);
Early Exit with IsNotValid¶
Use the projection pattern for null-safety:
var attr = symbol.GetAttribute<StrongIdAttribute>();
if (attr.IsNotValid(out var valid))
return null; // Early exit
// valid is guaranteed non-null here
var query = valid.AsQuery<StrongIdAttributeQuery>();
Chaining with Map and OrDefault¶
// OrDefault for optional values with fallbacks
var maxRetries = attr.NamedArg<int>("MaxRetries").OrDefault(3);
// OrNull for truly optional values
var prefix = attr.NamedArg<string>("Prefix").OrNull();
// OrThrow for required values
var id = attr.NamedArg<string>("Id").OrThrow("Id is required");
// OrElse for fallback chains
var target = symbol.GetAttribute<PrimaryAttribute>()
.OrElse(() => symbol.GetAttribute<FallbackAttribute>());