Attribute Utilities¶
Verified by tests
AttributeUtilitiesTests, MessageTagDiscoveryGeneratorTests — library CI run #31657041675 (2026-08-13)
The AttributeUtilities class provides shared utilities for extracting attribute values in Roslyn source generators. It supports all C# attribute parameter patterns - named arguments, constructor arguments, and mixed syntax - while remaining fully AOT-compatible.
Why Shared Utilities?¶
C# attributes can receive values through multiple syntax patterns:
Why Shared Utilities?
// Named arguments
[AuditEvent(Tag = "order-created", Exclude = true)]
public record OrderCreated(Guid OrderId);
// Constructor arguments
[TenantTag("tenants")]
public record TenantCreated(Guid TenantId);
// Mixed syntax (named takes precedence)
[DomainTag("ignored", Tag = "inventory")]
public record InventoryUpdated(Guid ProductId);
Without shared utilities, each generator must implement its own extraction logic, leading to:
| Problem | Impact |
|---|---|
| Code duplication | Same extraction logic in multiple generators |
| Inconsistent behavior | Some generators miss constructor arguments |
| Maintenance burden | Fixing bugs requires changes in multiple places |
| Testing overhead | Each implementation needs its own tests |
Solution: Centralized utilities shared across ALL generators via ILRepack.
How It Works¶
1. Roslyn AttributeData¶
The AttributeData type exposes attribute values through two properties:
flowchart TD
AD["AttributeData"]
NA["NamedArguments<br/>KeyValuePairs for named arguments"]
NAex["[Tag = #quot;value#quot;, Exclude = true]"]
CA["ConstructorArguments<br/>Indexed values from constructor"]
CAex["[#quot;value#quot;, true] (positional)"]
AD --> NA
NA --> NAex
AD --> CA
CA --> CAex
class AD,NA,CA layer-infrastructure
2. Value Precedence¶
Named arguments always take precedence over constructor arguments:
Value Precedence
// Given this attribute usage:
[MyTag("from-ctor", Tag = "from-named")]
// AttributeUtilities returns "from-named" for Tag
// Named argument overrides constructor argument
3. Case-Insensitive Parameter Matching¶
Constructor parameters are matched case-insensitively to property names:
Case-Insensitive Parameter Matching
// Attribute definition
public class TenantTagAttribute : MessageTagAttribute {
public TenantTagAttribute(string tag) { // lowercase "tag"
Tag = tag; // PascalCase "Tag"
}
}
// Usage
[TenantTag("tenants")]
// AttributeUtilities.GetStringValue(attr, "Tag") returns "tenants"
// Matches "tag" parameter to "Tag" property
Available Methods¶
GetStringValue¶
Extracts a string property value from an attribute.
GetStringValue
Example:
GetStringValue (2)
// Named argument
[NotificationTag(Tag = "orders")]
var tag = AttributeUtilities.GetStringValue(attr, "Tag"); // "orders"
// Constructor argument
[TenantTag("tenants")]
var tag = AttributeUtilities.GetStringValue(attr, "Tag"); // "tenants"
// Missing property
[NotificationTag]
var tag = AttributeUtilities.GetStringValue(attr, "Tag"); // null
GetBoolValue¶
Extracts a boolean property value from an attribute.
GetBoolValue
Example:
GetBoolValue (2)
// Named argument
[AuditEvent(Exclude = true)]
var exclude = AttributeUtilities.GetBoolValue(attr, "Exclude", false); // true
// Constructor argument
[SelectiveAudit("payments", true)]
var exclude = AttributeUtilities.GetBoolValue(attr, "Exclude", false); // true
// Missing property - returns default
[AuditEvent(Tag = "orders")]
var exclude = AttributeUtilities.GetBoolValue(attr, "Exclude", false); // false
GetIntValue¶
Extracts an integer property value from an attribute.
GetIntValue
Example:
GetIntValue (2)
// Named argument
[RetryPolicy(MaxAttempts = 5)]
var attempts = AttributeUtilities.GetIntValue(attr, "MaxAttempts", 3); // 5
// Constructor argument
[PriorityTag("orders", 100)]
var priority = AttributeUtilities.GetIntValue(attr, "Priority", 0); // 100
// Missing property - returns default
[NotificationTag(Tag = "orders")]
var priority = AttributeUtilities.GetIntValue(attr, "Priority", 50); // 50
GetStringArrayValue¶
Extracts a string array property value from an attribute.
GetStringArrayValue
Example:
GetStringArrayValue (2)
// Named argument
[NotificationTag(Properties = new[] { "OrderId", "CustomerId" })]
var props = AttributeUtilities.GetStringArrayValue(attr, "Properties");
// ["OrderId", "CustomerId"]
// Constructor argument
[SelectiveTag("users", new[] { "UserId", "Email" })]
var props = AttributeUtilities.GetStringArrayValue(attr, "Properties");
// ["UserId", "Email"]
// Missing property
[NotificationTag(Tag = "orders")]
var props = AttributeUtilities.GetStringArrayValue(attr, "Properties");
// null
Usage in Generators¶
MessageTagDiscoveryGenerator Example¶
MessageTagDiscoveryGenerator Example
// Simplified from _extractTagInfos in MessageTagDiscoveryGenerator.
// The real method yields one MessageTagInfo per tag attribute -
// a type can carry MULTIPLE MessageTagAttribute subclasses.
private static IEnumerable<MessageTagInfo> _extractTagInfos(
GeneratorSyntaxContext context,
CancellationToken ct) {
var typeDecl = (TypeDeclarationSyntax)context.Node;
var typeSymbol = context.SemanticModel.GetDeclaredSymbol(typeDecl, ct);
if (typeSymbol is null || typeSymbol.DeclaredAccessibility != Accessibility.Public) {
yield break;
}
// Find ALL MessageTagAttribute (or derived) attributes on the type
var tagAttributes = typeSymbol.GetAttributes()
.Where(a => _inheritsFromMessageTagAttribute(a.AttributeClass));
foreach (var tagAttribute in tagAttributes) {
// Extract values using shared utilities
// Works with both constructor and named arguments!
var tag = AttributeUtilities.GetStringValue(tagAttribute, "Tag") ?? "";
var properties = AttributeUtilities.GetStringArrayValue(tagAttribute, "Properties");
var extraJson = AttributeUtilities.GetStringValue(tagAttribute, "ExtraJson");
// Skip attributes with Exclude = true
var exclude = AttributeUtilities.GetBoolValue(tagAttribute, "Exclude", false);
if (exclude) {
continue;
}
yield return new MessageTagInfo(
Tag: tag,
Properties: properties,
ExtraJson: extraJson
// ... other properties (type names, attribute name, initializers)
);
}
}
Creating Custom Attributes¶
When creating custom attributes that inherit from Whizbang base attributes, you can use any C# parameter syntax:
Named-Only Pattern¶
Named-Only Pattern
// Attribute with required init properties (C# 11+)
public class NotificationTagAttribute : MessageTagAttribute {
public required string Tag { get; init; }
public string[]? Properties { get; init; }
}
// Usage
[NotificationTag(Tag = "orders", Properties = ["OrderId"])]
public record OrderCreated(Guid OrderId);
Constructor Pattern¶
Constructor Pattern
// Attribute with constructor parameter
public class TenantTagAttribute : MessageTagAttribute {
public TenantTagAttribute(string tag) {
Tag = tag;
}
}
// Usage
[TenantTag("tenants")]
public record TenantCreated(Guid TenantId);
Mixed Pattern¶
Mixed Pattern
// Attribute with constructor + optional named arguments
public class DomainTagAttribute : MessageTagAttribute {
public DomainTagAttribute(string tag) {
Tag = tag;
}
public string[]? Properties { get; set; }
}
// Usage - constructor for required, named for optional
[DomainTag("inventory", Properties = new[] { "ProductId" })]
public record InventoryUpdated(Guid ProductId, int Quantity);
AOT Compatibility¶
All utilities use only Roslyn's AttributeData APIs - no reflection:
| API | Source | AOT Safe |
|---|---|---|
AttributeData.NamedArguments |
Roslyn | Yes |
AttributeData.ConstructorArguments |
Roslyn | Yes |
AttributeData.AttributeConstructor |
Roslyn | Yes |
IParameterSymbol.Name |
Roslyn | Yes |
This ensures generators work with: - Native AOT compilation - Trimmed applications - Single-file publishing
ILRepack Integration¶
AttributeUtilities lives in Whizbang.Generators.Shared, which is merged into each generator assembly via ILRepack (ILRepack.Lib.MSBuild.Task, enabled with ILRepackEnabled in each generator's .csproj):
flowchart TD
subgraph DLL1["Whizbang.Generators.dll"]
Main1["Whizbang.Generators (main)"]
Shared1["Whizbang.Generators.Shared (merged)"]
AU1["AttributeUtilities.cs"]
Shared1 --> AU1
end
subgraph DLL2["Whizbang.Transports.HotChocolate.Generators.dll"]
Main2["Whizbang.Transports.HotChocolate.Generators (main)"]
Shared2["Whizbang.Generators.Shared (merged)"]
AU2["AttributeUtilities.cs (same code!)"]
Shared2 --> AU2
end
class Main1,Main2 layer-infrastructure
class Shared1,Shared2,AU1,AU2 layer-core
This means: - Consistent behavior across all generators - Single source of truth for extraction logic - Bug fixes benefit all generators automatically
Testing¶
Comprehensive unit tests verify all extraction scenarios:
Testing
[Test]
public async Task GetStringValue_ConstructorArgument_ReturnsValueAsync() {
var source = @"
[TestAttribute(""my-tag"")]
public class TestClass { }
";
var compilation = GeneratorTestHelper.CreateCompilation(source);
var typeSymbol = compilation.GetTypeByMetadataName("TestClass")!;
var attribute = typeSymbol.GetAttributes()[0];
var result = AttributeUtilities.GetStringValue(attribute, "Tag");
await Assert.That(result).IsEqualTo("my-tag");
}
[Test]
public async Task GetStringValue_BothPresent_NamedTakesPrecedenceAsync() {
var source = @"
[TestAttribute(""constructor-value"", Tag = ""named-value"")]
public class TestClass { }
";
var compilation = GeneratorTestHelper.CreateCompilation(source);
var typeSymbol = compilation.GetTypeByMetadataName("TestClass")!;
var attribute = typeSymbol.GetAttributes()[0];
var result = AttributeUtilities.GetStringValue(attribute, "Tag");
// Named argument wins
await Assert.That(result).IsEqualTo("named-value");
}
Related Documentation¶
- Message Tag Discovery - Uses AttributeUtilities for tag extraction
- Receptor Discovery - Generator patterns overview
- Aggregate IDs - Another generator using attribute extraction