Auto-Populate Attributes¶
Verified by tests
PopulateTimestampAttributeTests, PopulateFromServiceAttributeTests, PopulateFromIdentifierAttributeTests, PopulateFromContextAttributeTests, AutoPopulateHttpHeaderTests, AutoPopulateProcessorTests, MessageEnvelopeAutoPopulateExtensionsTests, JsonAutoPopulateHelperTests, AutoPopulateDiscoveryGeneratorTests — library CI run #31657041675 (2026-08-13)
Auto-populate attributes automatically enrich message properties with contextual data -- timestamps, service instance information, security context, message identifiers, and captured HTTP headers -- at dispatch time. Values are populated via source-generated code with zero reflection, making the entire feature fully AOT-compatible.
Namespace¶
Namespace
using Whizbang.Core.Attributes; // Attributes and kind enums
using Whizbang.Core.AutoPopulate; // Processor, registry, extensions
Overview¶
Instead of manually setting observability and audit fields on every message, auto-populate attributes let you declare what data a property should receive. The source generator discovers these attributes at compile time and generates populator code that uses record with expressions for zero-reflection population.
Overview
public record OrderCreated(
[property: StreamId] Guid OrderId,
string ProductName,
[property: PopulateTimestamp(TimestampKind.SentAt)] DateTimeOffset? SentAt = null,
[property: PopulateTimestamp(TimestampKind.QueuedAt)] DateTimeOffset? QueuedAt = null,
[property: PopulateTimestamp(TimestampKind.DeliveredAt)] DateTimeOffset? DeliveredAt = null,
[property: PopulateFromService(ServiceKind.ServiceName)] string? ProcessedBy = null,
[property: PopulateFromContext(ContextKind.UserId)] string? CreatedBy = null,
[property: PopulateFromIdentifier(IdentifierKind.CorrelationId)] Guid? WorkflowId = null
) : IEvent;
Values are stored in the MessageEnvelope metadata to preserve message immutability. Access them via envelope extension methods or use Materialize<T>() to create a new message instance with the populated values baked in.
Attributes¶
PopulateTimestamp¶
Marks a DateTimeOffset or DateTimeOffset? property for automatic timestamp population at a specific point in the message lifecycle.
PopulateTimestamp Attribute
public record PaymentProcessed(
[property: StreamId] Guid PaymentId,
decimal Amount,
[property: PopulateTimestamp(TimestampKind.SentAt)] DateTimeOffset? SentAt = null,
[property: PopulateTimestamp(TimestampKind.QueuedAt)] DateTimeOffset? QueuedAt = null,
[property: PopulateTimestamp(TimestampKind.DeliveredAt)] DateTimeOffset? DeliveredAt = null
) : IEvent;
TimestampKind Enum¶
| Value | Description | When Populated |
|---|---|---|
SentAt |
When dispatcher.SendAsync() or PublishAsync() is called |
At dispatch time |
QueuedAt |
After the message is written to the outbox and committed | By the outbox publisher worker |
DeliveredAt |
When the message arrives at the destination inbox | By the transport consumer worker |
QueuedAt and DeliveredAt only fire for distributed messages. Local-only dispatch skips those lifecycle stages.
PopulateFromService¶
Marks a property for automatic population from ServiceInstanceInfo. Useful for observability and distributed tracing to know which service instance processed a message.
PopulateFromService Attribute
public record OrderShipped(
[property: StreamId] Guid OrderId,
string TrackingNumber,
[property: PopulateFromService(ServiceKind.ServiceName)] string? ProcessedBy = null,
[property: PopulateFromService(ServiceKind.InstanceId)] Guid? InstanceId = null,
[property: PopulateFromService(ServiceKind.HostName)] string? HostName = null,
[property: PopulateFromService(ServiceKind.ProcessId)] int? ProcessId = null
) : IEvent;
ServiceKind Enum¶
| Value | Type | Description |
|---|---|---|
ServiceName |
string |
The name of the service (e.g., "OrderService") |
InstanceId |
Guid |
Unique identifier for the service instance in scaled deployments |
HostName |
string |
The host/machine name where the service runs |
ProcessId |
int |
The operating system process ID |
PopulateFromIdentifier¶
Marks a Guid or Guid? property for automatic population from message envelope identifiers. Essential for correlation, causation tracking, and saga patterns.
PopulateFromIdentifier Attribute
public record ShipmentDispatched(
[property: StreamId] Guid ShipmentId,
string TrackingNumber,
[property: PopulateFromIdentifier(IdentifierKind.MessageId)] Guid? MyMessageId = null,
[property: PopulateFromIdentifier(IdentifierKind.CorrelationId)] Guid? WorkflowId = null,
[property: PopulateFromIdentifier(IdentifierKind.CausationId)] Guid? TriggeredBy = null,
[property: PopulateFromIdentifier(IdentifierKind.StreamId)] string? StreamIdentifier = null
) : IEvent;
IdentifierKind Enum¶
| Value | Type | Description |
|---|---|---|
MessageId |
Guid |
The current message's unique identifier |
CorrelationId |
Guid |
Links all messages in a workflow or saga |
CausationId |
Guid |
The ID of the message that caused this one |
StreamId |
string |
The stream/aggregate this message belongs to |
PopulateFromContext¶
Marks a string property for automatic population from the current security context. Useful for audit trails and multi-tenancy.
PopulateFromContext Attribute
public record DocumentCreated(
[property: StreamId] Guid DocumentId,
string Title,
[property: PopulateFromContext(ContextKind.UserId)] string? CreatedBy = null,
[property: PopulateFromContext(ContextKind.TenantId)] string? TenantId = null
) : IEvent;
ContextKind Enum¶
| Value | Type | Description |
|---|---|---|
UserId |
string |
The current user's identifier from SecurityContext |
TenantId |
string |
The current tenant's identifier from SecurityContext |
PopulateFromHttpHeader¶
Marks a string property for automatic population from an inbound HTTP header captured at the request edge. The header value is captured into the ambient scope's extensions at the HTTP boundary (via the transport's ExtensionHeaderMappings, e.g. WhizbangScopeMiddleware in Whizbang.Transports.HotChocolate) and rides the message context; the generated populator reads it back onto the property at the Sent phase. The extension key is matched case-insensitively and by convention is the header name itself. Header values are opaque strings, so the target property must be a string.
PopulateFromHttpHeader Attribute
public record DocumentCreated(
[property: StreamId] Guid DocumentId,
string Title,
[property: PopulateFromHttpHeader("X-Correlation-ID")] string? CorrelationId = null
) : IEvent;
Unlike the other four attributes, PopulateFromHttpHeader takes the header/extension key as a string argument rather than a kind enum.
Applies To¶
All five attributes can be applied to:
- Properties on event types (implementing
IEvent) - Properties on command types (implementing
ICommand) - Record parameters (using
[property: ...]attribute target syntax)
Attribute Targets
// On record parameters (preferred for positional records)
public record OrderCreated(
[property: StreamId] Guid OrderId,
[property: PopulateTimestamp(TimestampKind.SentAt)] DateTimeOffset? SentAt = null
) : IEvent;
// On properties (class-style records or classes)
public record InventoryReserved : IEvent {
[StreamId]
public Guid ReservationId { get; init; }
[PopulateTimestamp(TimestampKind.SentAt)]
public DateTimeOffset? SentAt { get; init; }
[PopulateFromContext(ContextKind.UserId)]
public string? ReservedBy { get; init; }
}
How It Works¶
1. Compile-Time Discovery¶
The AutoPopulateDiscoveryGenerator source generator scans all message types for auto-populate attributes and generates two classes per assembly:
GeneratedAutoPopulateRegistry_{Assembly}-- implementsIAutoPopulateRegistry, providing metadata about which properties on which types need populationGeneratedAutoPopulatePopulator_{Assembly}-- implementsIAutoPopulatePopulator, using recordwithexpressions to produce new message instances with populated values
Both classes self-register via [ModuleInitializer] at assembly load time -- no manual wiring required.
2. Three Lifecycle Phases¶
Population happens at three distinct points in the message lifecycle:
Lifecycle Phases
// Phase 1: Sent -- when dispatcher.SendAsync/PublishAsync is called
// Populates: TimestampKind.SentAt, all ServiceKind, all ContextKind, all IdentifierKind,
// and PopulateFromHttpHeader values (from the scope's extensions)
// Phase 2: Queued -- after message is written to the outbox
// Populates: TimestampKind.QueuedAt
// Phase 3: Delivered -- when message arrives from transport
// Populates: TimestampKind.DeliveredAt
3. Immutability Preservation¶
Auto-populated values are stored in the MessageEnvelope metadata with an auto: prefix, preserving the original message immutability. The generated populator uses record with expressions to create new instances:
Generated Populator
// Generated code (simplified) -- you never write this
public object? TryPopulateSent(object message, MessageHop hop, MessageId messageId) {
return message switch {
OrderCreated m => m with {
SentAt = hop.Timestamp,
ProcessedBy = hop.ServiceInstance.ServiceName,
CreatedBy = _extractUserId(hop),
WorkflowId = hop.CorrelationId?.Value.Value,
},
_ => null
};
}
Reading Auto-Populated Values¶
From the Envelope¶
Use the extension methods on IMessageEnvelope to retrieve auto-populated values:
Reading Values from Envelope
// Get a specific auto-populated value
var sentAt = envelope.GetAutoPopulated<DateTimeOffset>("SentAt");
var userId = envelope.GetAutoPopulated<string>("CreatedBy");
var correlationId = envelope.GetAutoPopulated<Guid>("WorkflowId");
// Try-get pattern
if (envelope.TryGetAutoPopulated<string>("CreatedBy", out var createdBy)) {
Console.WriteLine($"Created by: {createdBy}");
}
// Check existence
bool hasSentAt = envelope.HasAutoPopulated("SentAt");
// List all auto-populated keys
IEnumerable<string> keys = envelope.GetAllAutoPopulatedKeys();
JSON Payload Manipulation¶
For transport workers that operate on serialized payloads, JsonAutoPopulateHelper provides AOT-safe timestamp population on JsonElement values:
JSON Auto-Populate
// By Type reference
var updatedPayload = JsonAutoPopulateHelper.PopulateTimestamp(
payload: jsonElement,
messageType: typeof(OrderCreated),
kind: TimestampKind.QueuedAt,
timestamp: DateTimeOffset.UtcNow
);
// By type name (AOT-safe, avoids Type.GetType())
var updatedPayload = JsonAutoPopulateHelper.PopulateTimestampByName(
payload: jsonElement,
messageTypeName: "MyApp.Events.OrderCreated",
kind: TimestampKind.DeliveredAt,
timestamp: DateTimeOffset.UtcNow
);
Practical Example¶
A complete event with full observability using four attribute categories:
Complete Observability Event
public record InvoiceGenerated(
[property: StreamId] Guid InvoiceId,
decimal TotalAmount,
string Currency,
// Lifecycle timestamps
[property: PopulateTimestamp(TimestampKind.SentAt)] DateTimeOffset? SentAt = null,
[property: PopulateTimestamp(TimestampKind.QueuedAt)] DateTimeOffset? QueuedAt = null,
[property: PopulateTimestamp(TimestampKind.DeliveredAt)] DateTimeOffset? DeliveredAt = null,
// Service info
[property: PopulateFromService(ServiceKind.ServiceName)] string? GeneratedByService = null,
[property: PopulateFromService(ServiceKind.InstanceId)] Guid? ServiceInstanceId = null,
// Security context
[property: PopulateFromContext(ContextKind.UserId)] string? GeneratedBy = null,
[property: PopulateFromContext(ContextKind.TenantId)] string? TenantId = null,
// Message identifiers
[property: PopulateFromIdentifier(IdentifierKind.CorrelationId)] Guid? WorkflowId = null,
[property: PopulateFromIdentifier(IdentifierKind.CausationId)] Guid? TriggeredBy = null
) : IEvent;
After dispatch, every field from SentAt downward is automatically populated. You never write manual assignment code for these properties.
API Reference¶
Attributes¶
| Attribute | Kind Enum | Property Types | Source |
|---|---|---|---|
PopulateTimestampAttribute |
TimestampKind |
DateTimeOffset, DateTimeOffset? |
Message lifecycle timestamps |
PopulateFromServiceAttribute |
ServiceKind |
string, Guid, int |
ServiceInstanceInfo |
PopulateFromIdentifierAttribute |
IdentifierKind |
Guid, Guid?, string |
MessageEnvelope identifiers |
PopulateFromContextAttribute |
ContextKind |
string |
SecurityContext |
PopulateFromHttpHeaderAttribute |
— (header name string) | string |
HTTP headers captured into the scope's extensions |
PopulateKind Enum¶
The PopulateKind enum categorizes registrations internally:
| Value | Description |
|---|---|
Timestamp |
Lifecycle timestamps (SentAt, QueuedAt, DeliveredAt) |
Context |
Security context values (UserId, TenantId) |
Service |
Service instance information (ServiceName, InstanceId, HostName, ProcessId) |
Identifier |
Message identifiers (MessageId, CorrelationId, CausationId, StreamId) |
Header |
HTTP header values captured at the request edge (keyed by header name, not an enum) |
Key Interfaces¶
| Interface | Purpose |
|---|---|
IAutoPopulateRegistry |
Per-assembly registry of auto-populate registrations, generated by the source generator |
IAutoPopulateProcessor |
Processes registrations and stores values in envelope metadata |
IAutoPopulatePopulator |
Per-assembly populator using record with expressions for typed population |
Key Classes¶
| Class | Purpose |
|---|---|
AutoPopulateRegistry |
Static aggregator of all IAutoPopulateRegistry instances across loaded assemblies |
AutoPopulatePopulatorRegistry |
Static aggregator of all IAutoPopulatePopulator instances across loaded assemblies |
AutoPopulateProcessor |
Default IAutoPopulateProcessor implementation; extracts values from hops and stores them as metadata |
AutoPopulateRegistration |
Record describing a single auto-populated property (message type, property name, kind, etc.) |
MessageEnvelopeAutoPopulateExtensions |
Extension methods for IMessageEnvelope to read auto-populated values |
JsonAutoPopulateHelper |
AOT-safe helper for populating timestamps on serialized JSON payloads |
AutoPopulateDiscoveryGenerator |
Source generator that discovers auto-populate attributes and generates registry and populator code |
Zero Reflection / AOT¶
The auto-populate system is fully AOT-compatible:
- Discovery happens at compile time via the
AutoPopulateDiscoveryGeneratorsource generator - Registration uses
[ModuleInitializer]for automatic self-registration at assembly load - Population uses record
withexpressions -- direct property assignment with no reflection - JSON manipulation uses
System.Text.Json.Nodes.JsonNodewhich is fully AOT-compatible - Serialization uses
InfrastructureJsonContextsource-generated JSON serializer context
No Type.GetType(), no PropertyInfo.SetValue(), no runtime reflection of any kind.
See Also¶
- StreamId Attribute -- Marks the stream ID property
- GenerateStreamId Attribute -- Auto-generate StreamIds at dispatch time
- StreamKey Attribute -- Event ordering for perspectives