Aggregate IDs¶
Verified by tests
StreamIdGeneratorTests, StreamIdGeneratorCoverageTests, GenerateStreamIdGeneratorTests, StreamIdInfoTests — library CI run #31657041675 (2026-08-13)
Updated
Renamed in the shipped library (verified at commit 0bc6065b): the early AggregateIdGenerator + [AggregateId] design described by previous versions of this page was replaced by the StreamIdGenerator + [StreamId] attribute. "Aggregate ID" and "stream ID" refer to the same concept — the identifier of the stream (aggregate/entity) a message belongs to — and PolicyContext.GetAggregateId() is still the policy-facing accessor, but discovery, generation, and extraction all run through [StreamId].
The StreamIdGenerator discovers properties (or record parameters) marked with [StreamId] at compile-time and generates zero-reflection extractor methods. This lets the framework resolve which stream a message belongs to — for event sourcing, tracing, and policy decisions — without any runtime reflection.
Why Stream IDs?¶
Stream IDs identify the stream (aggregate) a message belongs to (Orders, Customers, Products). Whizbang uses them for:
| Use Case | Description | Example |
|---|---|---|
| Event Sourcing | Group all events for an aggregate into one stream | All events for Order #123 |
| Ordering | Events for the same stream process in order | Per-stream sequential drain |
| Policy Decisions | PolicyContext.GetAggregateId() for routing decisions |
High-value orders → priority handling |
| Delivery Receipts | IDeliveryReceipt.StreamId extraction |
Track outcome per stream |
| Partitioning | Distribute work across instances by stream | Order #789 → Instance 2 |
Problem: Extracting IDs at runtime requires reflection (slow, not AOT-compatible).
Solution: The generator discovers [StreamId] members at compile-time and generates zero-reflection extractors.
How It Works¶
1. Mark Properties with [StreamId]¶
Mark Properties with [StreamId]
using Whizbang.Core;
// Command (record parameter form)
public record CreateOrder(
[property: StreamId] Guid OrderId, // ← Marked as stream ID
Guid CustomerId
) : ICommand;
// Event (property form)
public record OrderCreated : IEvent {
[StreamId]
public Guid OrderId { get; set; }
public Guid CustomerId { get; init; }
public decimal Total { get; init; }
}
Requirements (from StreamIdAttribute):
- Property must be Guid, Guid?, or a WhizbangId type (a type whose value resolves to a Guid)
- Only one [StreamId] per message type
- The attribute is inherited by derived message types
2. Compile-Time Discovery¶
The generator runs four discovery pipelines over public record/class declarations with base lists:
IEventtypes with[StreamId](on a property or constructor parameter, including inherited members)IEventtypes without[StreamId]— reported as WHIZ009 warningsICommandtypes with[StreamId]- Concrete
ICompositeEventtypes with[StreamId]— composites areIMessage-not-IEvent, but carry a[StreamId]their fanned-out inner events inherit; they get an object-typed extractor so producer-side fan-out routes children correctly
Non-public types are skipped (generated code could not access them), and abstract composites (e.g., CompositeEventBase itself) are skipped.
3. Generated Code¶
One file, StreamIdExtractors.g.cs, is emitted into the {AssemblyName}.Generated namespace. Its public surface:
Generated Code
public static partial class StreamIdExtractors {
// Resolve stream ID as string (throws if the type has no [StreamId])
public static string Resolve(IEvent @event);
public static string Resolve(ICommand command);
// Try to resolve as Guid (returns null if missing/not parseable)
public static Guid? TryResolveAsGuid(IEvent? @event);
public static Guid? TryResolveAsGuid(ICommand? command);
public static Guid? TryResolveAsGuid(object? message); // composites, perspective DTOs
// [GenerateStreamId] support (used by the Dispatcher)
public static (bool ShouldGenerate, bool OnlyIfEmpty) GetGenerationPolicy(object message);
public static bool SetStreamId(object message, Guid streamId);
}
// Delegates to the static extractors; implements Whizbang.Core.IStreamIdExtractor
internal sealed class GeneratedStreamIdExtractor : IStreamIdExtractor { /* ... */ }
// DI hook: registers the composite extractor as IStreamIdExtractor
public static class StreamIdExtractorRegistrations {
public static IServiceCollection AddWhizbangStreamIdExtractor(this IServiceCollection services);
}
Each discovered type gets a type-dispatch case plus a per-type extractor, e.g.:
Generated Code (2)
// Dispatch (inside Resolve/TryResolveAsGuid)
if (@event is global::MyApp.Events.OrderCreated e0) {
return TryExtractAsGuid(e0);
}
// Per-type extractor for a Guid property
private static global::System.Guid? TryExtractAsGuid(global::MyApp.Events.OrderCreated @event) {
return @event.OrderId;
}
String and other property types are supported via Guid.TryParse on the value's string form; null or whitespace keys return null from TryResolveAsGuid (and throw from Resolve).
4. Multi-Assembly Registration¶
A [ModuleInitializer] in the generated file registers the assembly's extractor with the global StreamIdExtractorRegistry when the assembly loads — priority 100 for contracts assemblies, 1000 for services — so shared-contracts extractors are tried first. AddWhizbangStreamIdExtractor() (called by AddWhizbangDispatcher()) then registers the registry's composite as the DI IStreamIdExtractor.
Auto-Generating Stream IDs¶
Apply [GenerateStreamId] alongside [StreamId] to have the Dispatcher mint a stream ID at dispatch time:
GenerateStreamId
// Stream-initiating event: ALWAYS gets a new StreamId
public record OrderCreatedEvent : IEvent {
[StreamId] [GenerateStreamId]
public Guid OrderId { get; set; }
}
// Flexible event: inherits parent StreamId in cascades, generates if standalone
public record InventoryReserved : IEvent {
[StreamId] [GenerateStreamId(OnlyIfEmpty = true)]
public Guid ReservationId { get; set; }
}
// Class-level: for a [StreamId] inherited from a base class
[GenerateStreamId]
public record OrderCreatedFromBase : BaseEvent;
The generated GetGenerationPolicy returns (ShouldGenerate, OnlyIfEmpty) per type, and SetStreamId writes the minted value back through the [StreamId] property — which is why that property must be a mutable get; set; (see WHIZ013 below). Events with [StreamId] but without [GenerateStreamId] must have a stream ID assigned before dispatch.
Usage in PolicyContext¶
PolicyContext.GetAggregateId() resolves the IStreamIdExtractor from the service provider and extracts the stream ID from the current message:
PolicyContext Integration
// Inside a policy, the aggregate/stream ID comes from the generated extractor:
var aggregateId = context.GetAggregateId();
// Throws InvalidOperationException if:
// - IStreamIdExtractor is not registered, or
// - the message type has no property marked with [StreamId]
Diagnostics¶
| ID | Severity | Message |
|---|---|---|
| WHIZ004 | Info | Found [StreamId] on command {0}.{1} |
| WHIZ005 | Error | [StreamId] on {0}.{1} must be of type Guid, Guid?, or a type with a .Value property returning Guid |
| WHIZ006 | Warning | Type {0} has multiple [StreamId] attributes. Only the first property '{1}' will be used. |
| WHIZ009 | Warning | Type '{0}' implements {1} but has no property or parameter marked with [StreamId]. Stream ID resolution will fail at runtime. |
| WHIZ010 | Info | Found [StreamId] on {0}.{1} |
| WHIZ013 | Error | [GenerateStreamId] on an init-only [StreamId] property — the generated SetStreamId writer cannot target init, so the minted ID would silently never be written. Change the property to get; set;. |
Example build output:
info WHIZ010: Found [StreamId] on OrderCreated.OrderId
info WHIZ004: Found [StreamId] on command CreateOrder.OrderId
warning WHIZ009: Type 'LegacyEvent' implements IEvent but has no property or parameter marked with [StreamId]. Stream ID resolution will fail at runtime.
Patterns¶
Pattern 1: Record Parameter¶
Pattern 1: Record Parameter
Pattern 2: Nullable Guid¶
Pattern 2: Nullable Guid
public record OrderArchived : IEvent {
[StreamId]
public Guid? OrderId { get; set; } // TryResolveAsGuid returns null when unset
}
Pattern 3: Inherited [StreamId]¶
Pattern 3: Inherited [StreamId]
// Base class carries the attribute (Inherited = true)
public abstract record OrderEventBase : IEvent {
[StreamId]
public Guid OrderId { get; set; }
}
// Derived events are each discovered with the inherited property
public record OrderShipped(string TrackingNumber) : OrderEventBase;
public record OrderCancelled(string Reason) : OrderEventBase;
FindPropertyWithAttribute (see Type Symbol Extensions) walks the base-type chain, so the derived events each get their own extractor.
Best Practices¶
DO ✅¶
- ✅ Mark the stream identifier with
[StreamId]on everyIEventand stream-addressedICommand - ✅ Use Guid / Guid? / WhizbangId types for ID properties
- ✅ Use
[GenerateStreamId]for stream-initiating events;OnlyIfEmpty = truefor cascade-friendly events - ✅ Keep
[StreamId]+[GenerateStreamId]properties mutable (get; set;) so the generated writer can assign them - ✅ Use UUIDv7-style IDs (
TrackedGuid/WhizbangId types) for time-ordering
DON'T ❌¶
- ❌ Mark multiple properties (WHIZ006 — only the first is used)
- ❌ Use non-Guid-compatible types (WHIZ005 error)
- ❌ Leave events without
[StreamId](WHIZ009 — runtime resolution will fail) - ❌ Combine
[GenerateStreamId]with an init-only property (WHIZ013 error)
Troubleshooting¶
Problem: "No stream ID extractor found for event type ..."¶
Symptoms: Resolve() throws InvalidOperationException at runtime.
Causes:
1. The type has no [StreamId] property or parameter
2. The type is not public (generated code skips non-public types)
Solution: Problem: No extractor found
public record CreateOrder(
[property: StreamId] Guid OrderId, // Add attribute
Guid CustomerId
) : ICommand;
Problem: Second [StreamId] Ignored¶
Symptoms: Warning WHIZ006, second property not used.
Solution: Keep exactly one [StreamId] per message type.
Problem: Auto-Generated ID Stays Empty¶
Symptoms: Dispatch fails at the outbox; [GenerateStreamId] appears to do nothing.
Cause: The [StreamId] property is init-only, so SetStreamId cannot write to it (WHIZ013 reports this at build time).
Solution: Change the property to get; set;.
Further Reading¶
Source Generators: - Receptor Discovery - Compile-time receptor discovery - Perspective Discovery - Compile-time perspective discovery - Type Symbol Extensions - Inherited-property discovery used by this generator - JSON Contexts - AOT-compatible JSON serialization
Core Concepts: - Message Context - MessageId, CorrelationId, CausationId - Observability - Distributed tracing with hops
Data Access: - Event Store - Event sourcing and stream storage
Infrastructure: - Policies - Policy-based routing and decisions
Version 1.0.0 - Foundation Release | Last Updated: 2026-07-16