Source Generator Behaviors
Source Generator Behaviors
Section titled “Source Generator Behaviors”This page documents framework-level source-generator behavior (not storage-provider behavior).
Aggregate eligibility and inheritance
Section titled “Aggregate eligibility and inheritance”[Aggregate] supports three inheritance paths:
- No declared base class: generated partial type automatically inherits
AggregateBase. - Direct inheritance from
AggregateBase. - Transitive inheritance through one or more intermediate base classes.
Other eligibility rules:
- Aggregate type must be
partial. - Nested and generic aggregate types are not supported.
RegisterEvents()is generated and cannot be manually declared.
Inheritance examples
Section titled “Inheritance examples”// 1) No declared base class (generator adds AggregateBase on generated partial)[Aggregate]public partial class ProductAggregate{ [Event] public partial void Create(string name);}
// 2) Direct inheritance[Aggregate]public partial class OrderAggregate : AggregateBase{ [Event] public partial void CreateOrder(string customerId);}
// 3) Transitive inheritancepublic abstract class DomainAggregateBase : AggregateBase { }public abstract class BillingAggregateBase : DomainAggregateBase { }
[Aggregate]public partial class InvoiceAggregate : BillingAggregateBase{ [Event] public partial void CreateInvoice(string invoiceNumber);}Generated event naming and namespace
Section titled “Generated event naming and namespace”Default event namespace:
<AggregateNamespace>.<AggregateNameWithoutSuffix>Events- Example:
Testing.OrderAggregate->Testing.OrderEvents
Default event type naming:
- Event names are inferred from method names (or overridden with
EventName = ...). - Event type suffix defaults to
Event(configurable withEventSuffixdefaults/overrides). - Typical generated type:
Testing.OrderEvents.OrderCreatedEvent.
Namespace can be overridden per method (EventNamespace) or by aggregate defaults.
Event naming examples
Section titled “Event naming examples”namespace Testing;
[Aggregate]public partial class OrderAggregate : AggregateBase{ [Event] public partial void CreateOrder(string customerId);
[Event(EventName = "OrderRegistered", EventNamespace = "Testing.Custom.Events")] public partial void RegisterOrder(string customerId);}Typical generated types:
Testing.OrderEvents.OrderCreatedEvent(default namespace/name)Testing.Custom.Events.OrderRegistered(explicit namespace/name)
An explicit
EventNameis used verbatim: the generator does not append theEventsuffix when a name is provided. Include the suffix in the explicit name (for exampleEventName = "OrderRegisteredEvent") if you want the generated type to end inEvent. TheEventsuffix is only appended to inferred names.
Hook behavior semantics
Section titled “Hook behavior semantics”Property hooks are property-scoped:
On<Property>Changing(ref value)runs on generated command methods before event creation.On<Property>Changed(previous, current)runs in generatedApply(...)after assignment.- If different events update the same property, the same property hooks run for each.
- Hooks run only when the event method maps that property.
Replay behavior:
- Replay executes generated
Apply(...). On<Property>Changedruns on replay.On<Property>Changingdoes not run on replay.
Event hooks are event-scoped:
OnRaising<EventName>Event(ref ...)OnRaised<EventName>Event(@event)OnApplied<EventName>Event(@event)OnShouldApply<EventName>Event(@event, ref bool shouldApply)
Manual behavior:
Manual = truedoes not auto-wire property hooks unless manual code invokes them.
Property hook example
Section titled “Property hook example”[Aggregate]public partial class CustomerAggregate : AggregateBase{ public string Email { get; private set; } = string.Empty;
[Event(EventName = "CustomerRegistered")] public partial void Register(string email);
[Event(EventName = "CustomerEmailChanged")] public partial void ChangeEmail(string email);
partial void OnEmailChanging(ref string email) => email = email.Trim().ToLowerInvariant(); partial void OnEmailChanged(string previous, string current) { /* audit */ }}OnEmailChanging/Changed run for both Register and ChangeEmail because both map to Email.
Event method mapping and validation
Section titled “Event method mapping and validation”[Event]methods must bepartialdeclarations without bodies.- Return types must be
void,bool, or the containing aggregate type. - Parameters must map to writable aggregate properties unless explicitly handled as metadata/manual payload.
- Collection event methods (
[CollectionEvent]) requireEventStoreList<T>/EventStoreSet<T>target properties.
Event contracts
Section titled “Event contracts”Events are emitted as [EventContract] sealed record types — pure payload data with no base class
or interface:
[EventContract]public sealed record OrderCreatedEvent{ public static int SchemaVersion => 1; [JsonIgnore] public EventMetadata Metadata { get; init; } public string CustomerId { get; set; }}[EventContract]marks the type as an event contract (the generator, analyzer, and upcasting registry use it to recognise event types; hand-written events registered viaRegister<TEvent>/RegisterGenerated<TEvent>must be marked with it too —EVENTSTORE037).Metadata(EventMetadata, a readonly record struct) carries framework-managed metadata (aggregate version, timestamp, schema version, idempotency/correlation/causation/user ids). It is[JsonIgnore]d, so event payloads no longer embed metadata; providers persist metadata to row columns and rehydrate it on replay.GetHashCodeis content-based for payload properties and metadata, preserving stable event hashing (used by the Azure idempotency compound key).- The generated
RegisterEventsregisters appliers withRegisterGenerated<TEvent>(), which resolves the generatedApply(TEvent)method once per aggregate/event type and shares it statically, so aggregate construction allocates no per-instance applier delegates.
Generated command method shape
Section titled “Generated command method shape”A generated command method constructs one event instance, runs the property On<Property>Changing
hooks, evaluates OnShouldApply before OnRaising, runs OnRaising/OnComputing hooks (which may
mutate parameters via ref), re-synchronizes the event’s payload properties from the post-hook
values, re-evaluates OnShouldApply, then records the event via RecordAndApply. The single
allocation keeps command invocation allocation-light; the post-hook re-synchronization preserves the
exact payload values that a second construction would have produced.
Example
Section titled “Example”[Aggregate]public partial class ReportAggregate : AggregateBase{ public EventStoreSet<string> Tags { get; private set; } = [];
[CollectionEvent(nameof(Tags))] public partial void AddTag(string tag);}Parameter nullability and required guards
Section titled “Parameter nullability and required guards”The generator honors two standard attributes on event parameters to tighten command-time validation and the shape of the generated event class:
[NotNull](System.Diagnostics.CodeAnalysis) on a nullable parameter generates anArgumentNullExceptionguard and emits the event property as non-nullable.[Required](System.ComponentModel.DataAnnotations) on a nullablestringparameter generates anArgumentExceptionguard for null or whitespace and emits the event property as non-nullable.
Both attributes also cause the generator to use a local copy of the parameter value when calling On...Changing hooks and when creating the event. This keeps the original parameter unmodified so the compiler does not require it to be assigned after a throw path.
[Aggregate]public partial class ProfileAggregate : AggregateBase{ public string? Bio { get; private set; }
[Event] public partial void UpdateBio([NotNull] string? bio);}For the event above, the generator produces a property typed as string rather than string?:
public sealed class BioUpdatedEvent : global::Purview.EventSourcing.Aggregates.Events.EventBase{ public string Bio { get; set; } = default!;}Value-object conversion behavior
Section titled “Value-object conversion behavior”- Generated mapping paths use
Create(...)semantics for strict command-time conversion/validation. - Contextual
Create(TValue, in ValueObjectContext<TAggregate>)is used when available. - Replay/hydration paths apply event payloads through generated
Apply(...)logic. - Snapshot-query translation depends on how the provider maps the resulting property graph, not only on the value-object generator behavior.
- Projects compiled with the SQL Server or PostgreSQL EF analyzer can mark a property
[EfOpaque]. The EF-only generator emits this internal marker into the consuming compilation; it does not add a runtime attribute API. EVENTSTOREEF001reports dictionary-like members reachable from an aggregate unless they are explicitly opaque. Prefer a collection of domain entry objects when structural querying is required; the generator does not synthesize those domain types.EVENTSTOREEF002reports uses of an opaque member in recognized snapshot query expressions. Opaque values round-trip through JSON but their contents are not part of EF’s queryable complex model.- A
[Scalar]value object that wraps a complex CLR type may serialize correctly while still requiring a separate directly mapped complex mirror property for deep SQL predicates.
Value-object conversion examples
Section titled “Value-object conversion examples”// Scalar conversion[Scalar]public readonly partial record struct EmailAddress{ public string Value { get; } static partial void OnNormalize(ref string value) => value = value.Trim().ToLowerInvariant(); static partial void OnValidate(string value) { /* format checks */ }}// Contextual conversion[Scalar]public readonly partial record struct OrderStatus : IContextualValueObject<OrderStatus, OrderStatusCode, OrderAggregate>{ public OrderStatusCode Value { get; }
public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext<OrderAggregate> context) => IsValidTransition(context.Aggregate.Status.Value, value) ? new(value) : throw new InvalidOperationException();}Diagnostics to expect
Section titled “Diagnostics to expect”Validation diagnostics are produced by Purview.EventSourcing.SourceGenerator analyzers
(AggregateDiagnosticAnalyzer, ValueObjectDiagnosticAnalyzer, and EventStoreAnalyzer), not by the
source generators themselves. The generators consume the same validation internally to decide whether to
emit source, but they never report diagnostics. Analyzer diagnostics can be suppressed or configured
through the usual #pragma warning / .editorconfig mechanisms.
Common aggregate diagnostic IDs:
EVENTSTORE001aggregate must be partialEVENTSTORE002aggregate must inheritAggregateBase(or have no base so generator can add it)EVENTSTORE003nested aggregates unsupportedEVENTSTORE004generic aggregates unsupportedEVENTSTORE005manualRegisterEventsunsupportedEVENTSTORE007generated event method must be partialEVENTSTORE009duplicate generated event namesEVENTSTORE010parameter must map to writable propertyEVENTSTORE018unsupported aggregate collection property typeEVENTSTORE021event schema version must be positiveEVENTSTORE022duplicate event schema version on aggregate
Common value-object diagnostic IDs:
EVENTSTORE101value object must be partialEVENTSTORE102nested value objects unsupportedEVENTSTORE103generic value objects unsupportedEVENTSTORE104scalar property missingEVENTSTORE107strict mode relies on a generatedCreateEVENTSTORE108conflicting[Scalar]and[ValueObject]attributesEVENTSTORE109scalar value objects should be record structs
The analyzer and the generator share the same validation rules (the model builders are the single source of truth). When validation fails, the generator skips generation entirely — it never emits an invalid partial type — while the analyzer reports the diagnostic. A generator-only run therefore produces no output and no exception for invalid input; the diagnostics are always surfaced by the analyzer assets that ship in the same package.
Testing generated output
Section titled “Testing generated output”Generator unit tests assert on the generated structure with the CodeQuery API from
Purview.SourceGeneratorFramework.Testing rather than whole-file string matching:
result.Generated()returns aCodeQueryover the generated trees (backed by the output compilation).- Prefer
GetClass/GetRecord/GetStruct/GetEnum/HasNamespace,HasMethod,HasProperty,HasConstructor, andTypeReference-based parameter matching for member signatures. - Keep string assertions only for method-body statements that
CodeQuerydoes not model (for exampleRecordAndApply(@event);), scoped to the returned syntax node’s body. - Operator declarations are
OperatorDeclarationSyntax, not methods; assert them viaCodeQuery.GetOperator/HasOperator/TryGetOperator(optionally scoped withCodeQuery.In(type)), orGetConversionOperatorforimplicit/explicitconversions.
Incremental caching is tested with the framework’s GenerateIncrementalAsync/RunIncrementalAsync, which
reuse one driver and compilation across identical runs. The framework-named stages
(GetGenerationConfiguration, GetGenerationContext_{Capabilities}, and the per-target
ForAttribute/target stage) must stay Cached/Unchanged on identical reruns, and only the stage whose
input actually changed reports Modified.