# Event Sourcing > Aggregate-based event sourcing for .NET across any store. A .NET event sourcing framework for building aggregate-based applications. Provider-agnostic store facades, source-generated aggregates and events, transaction coordination for multi-aggregate saves, queryable snapshots and read models, and storage providers for SQL Server, PostgreSQL, MongoDB, Azure Storage, Azure Cosmos DB, and in-memory. - Repository: https://github.com/purview-dev/event-sourcing - Package: https://www.nuget.org/packages/Purview.EventSourcing - Project page: https://purview.dev/projects/event-sourcing/ - Documentation: https://purview.dev/docs/event-sourcing/ - Full machine-readable content: https://purview.dev/projects/event-sourcing/llms-full.txt # Getting Started ## Install ```bash dotnet add package Purview.EventSourcing ``` Add one or more provider packages based on your persistence target: ```bash dotnet add package Purview.EventSourcing.SqlServer dotnet add package Purview.EventSourcing.Postgres dotnet add package Purview.EventSourcing.AzureStorage dotnet add package Purview.EventSourcing.MongoDB dotnet add package Purview.EventSourcing.CosmosDb ``` Optional packages: ```bash # In-memory provider (local/test scenarios) dotnet add package Purview.EventSourcing.InMemory # Validation adapters dotnet add package Purview.EventSourcing.Validation.FluentValidation dotnet add package Purview.EventSourcing.Validation.ZodSharp ``` ## Dependency guardrail for Purview.ZodSharp If your project references the `Purview.EventSourcing.Validation.ZodSharp` project directly and uses `Purview.ZodSharp` types, you must add: ```xml ``` `Purview.EventSourcing.Validation.ZodSharp` ships a build-time check (`ValidateZodSharpDirectReference`) in package `buildTransitive` assets so consumer projects fail fast with remediation guidance when this direct package reference is missing. ## Define an aggregate (source generator) ```csharp using Purview.EventSourcing.Aggregates; [Aggregate] public partial class OrderAggregate : AggregateBase { public string CustomerId { get; private set; } = default!; public decimal Total { get; private set; } [Event] public partial void CreateOrder(string customerId); [Event] public partial void AddLineItem(string productId, string productName, int quantity, decimal unitPrice); } ``` ## Register storage ```csharp // SQL Server / Azure SQL (events + queryable snapshots) builder.Services.AddSqlServerEventStore(); builder.Services.AddSqlServerSnapshotQueryableEventStore(); ``` Other provider registrations: ```csharp // Azure Storage (event store with blob support) builder.Services.AddAzureStorageEventStore(); // PostgreSQL (events + queryable snapshots) builder.Services.AddPostgresEventStore(); builder.Services.AddPostgresSnapshotQueryableEventStore(); // MongoDB (events + queryable snapshots) builder.Services.AddMongoDBEventStore(); builder.Services.AddMongoDBSnapshotQueryableEventStore(); // Cosmos DB (queryable snapshots) builder.Services.AddCosmosDbSnapshotQueryableEventStore(); // Core-only fallback for projects without persistent query snapshots builder.Services.AddNullQueryableEventStore(); ``` ## Use the provider-agnostic facade ```csharp public sealed class OrderService(IEventStore store) { public async Task PlaceOrderAsync(string orderId, string customerId, CancellationToken cancellationToken) { var order = await store.GetAsync(orderId, cancellationToken) ?? await store.CreateAsync(orderId, cancellationToken: cancellationToken); order.CreateOrder(customerId); await store.SaveAsync(order, cancellationToken); } } ``` ## Query aggregate event history (time/range filters) ```csharp var history = await store.GetEventHistoryAsync( aggregateId: orderId, request: new AggregateEventHistoryRequest { FromVersion = 10, ToVersion = 50, FromUtc = DateTimeOffset.UtcNow.AddDays(-7), MaxRecords = 100 }, cancellationToken: cancellationToken); foreach (var item in history.Results) { Console.WriteLine($"{item.AggregateVersion} {item.When:u} {item.EventType}"); } ``` ## Next pages - [Guarantees and Limitations](guarantees-and-limitations/) - [Provider Feature Matrix](provider-feature-matrix/) - [Provider Capabilities](provider-capabilities/) - [Transaction Guarantees](transaction-guarantees/) - [Event Contract Manifest](event-contract-manifest/) - [Dependency Guardrails](dependency-guardrails/) - [Source Generator Behaviors](source-generator-behaviors/) - [Source Generator Code Fixes](code-fixes/) - [SQL Server Guide](sql-server-guide/) - [Release Flow](release-flow/) If you plan to query snapshot JSON deeply in SQL providers, read the SQL Server guide and provider matrix before relying on nested predicates through scalar value object `.Value` members. --- # Guarantees and Limitations This page is the single authoritative summary of what the framework guarantees and where it does not. Individual topics link to their detailed pages; do not duplicate conflicting claims elsewhere. ## Event ordering and optimistic concurrency - Event streams are the canonical source of aggregate truth. Snapshots are replaceable optimizations or read models. - Events within a stream are persisted in aggregate-version order. Writes to different aggregates never contend. - Providers detect conflicting writes (optimistic concurrency) and surface them as `ConcurrencyException`/`IConcurrencyConflict`; see `ConcurrencyRetry` and `AggregateWriteLock` for retry and in-process serialization. `EventStoreCapabilities.Concurrency` reports which providers are optimistic versus last-writer-wins. ## Transaction guarantees and failure modes See [Transaction Guarantees](../transaction-guarantees/) for the full contract. - `EventStoreTransactionGuarantee.BestEffort`: aggregates are saved sequentially; earlier saves are not rolled back on failure. - `EventStoreTransactionGuarantee.Atomic`: all enlisted aggregates commit or roll back in one provider-native transaction (SQL Server and PostgreSQL within one database boundary). - A transaction that requires a stronger guarantee than the enlisted stores can provide fails before any save (`EventStoreTransactionGuaranteeException`). Capability discovery reports the actual guarantee per provider. ## Idempotency scope - Saves deduplicate on an idempotency marker where the provider supports it (`EventStoreCapabilities.SupportsIdempotencyMarkers`). Idempotency is scoped to a save operation under a correlation/idempotency identifier; it is not a delivery guarantee for downstream consumers (which must be idempotent themselves). ## Metadata persistence Providers persist and expose event metadata where supported (`EventStoreCapabilities.PreservedMetadata`). The metadata fields are `SchemaVersion`, `CorrelationId`, `CausationId`, `UserId`, `IdempotencyId`, `AggregateVersion`, and `When`. A field that is not preserved by a provider is exposed as `null`/default. ## Schema evolution, manifests, and upcasters - [Event-Versioning-Strategy.md](../event-versioning-strategy/) describes how to evolve event schemas. - [Event-Contract-Manifest.md](../event-contract-manifest/) describes the deterministic contract manifest and baseline validation that fails a build on breaking changes. - Upcasters translate legacy payloads during replay. Treat emitted event names, serialized payloads, schema versions, and generated method signatures as compatibility-sensitive contracts. ## Snapshot compatibility and safe rebuild See [Snapshot-Schema-Versioning.md](../snapshot-schema-versioning/). - Snapshots must always be reconstructible from the event stream. - `[SnapshotSchemaVersion]` and `AggregateSnapshotSchema` drive version-aware snapshot storage. Incompatible snapshots are ignored before deserialization and canonical event replay is used instead; a later snapshot-eligible save writes a compatible replacement. ## Provider capability discovery See [Provider-Capabilities.md](../provider-capabilities/). Resolve `IEventStoreCapabilitiesProvider` from DI to query transaction guarantee, snapshot behavior, preserved metadata, query support, idempotency, concurrency, and operational limitations for the registered stores. The [Provider Feature Matrix](../provider-feature-matrix/) summarizes the same facts for package selection. ## Admin security, metadata/payload separation, and deny-by-default - Admin endpoints are denied by default and authorized per feature (`AdminFeature`, `AdminPortalPolicies`). `AdminEndpointOptions` lets a host map a feature to its own named authorization policy. - `ViewEvents` grants metadata access; event **payloads** are only returned with `ViewEventPayloads` permission. Without it, payloads are `null`. - Event export requires both export and payload permissions. Read permissions never imply mutation authority. Export is capped at `AdminProjectionOptions.MaxVersionsPerQuery`; a truncated stream is signaled with the `Purview-Event-Export-Truncated` response header so callers can detect partial exports. - Operational endpoints (`GET /admin/api/capabilities`, `GET /admin/api/health`, `GET /admin/api/manifest`, `GET /admin/api/outbox/poisoned`, `GET /admin/api/aggregates/{aggregateType}/{aggregateId}/events/unknown`, `GET /admin/api/aggregates/{aggregateType}/{aggregateId}/snapshot`, and the opt-in mutation `POST /admin/api/aggregates/{aggregateType}/{aggregateId}/snapshot/rebuild`) are opt-in, separately authorized, and audited through `IAdminAuditLogger` (default in-memory; replace with a durable implementation in production). Health reflects whether the capability contract resolves; it does not probe live storage. The manifest endpoint reports the runtime event-contract manifest and its compatibility status against a supplied baseline. A snapshot rebuild reconstructs the aggregate from its canonical event stream and persists a fresh snapshot; it is idempotent and requires both an event-backed `IEventStore` and a registered `IQueryableEventStore`. ## Query consistency and provider-specific translation limitations - Queryable stores are snapshot-backed read models; consistency is as-of-replay, not transactional. - SQL snapshot translation supports deep predicates over directly mapped JSON graphs, but provider-converted scalar value objects may not translate deep members through `.Value`; see the [Provider Feature Matrix](../provider-feature-matrix/) and [SQL Server Guide](../sql-server-guide/) for exact limits. ## Unknown-event handling and recovery - Replay of an unknown event type does not corrupt the stream: the aggregate reports `AggregateBase.SkippedEvents` so callers can detect partial reconstruction. - Event-schema versioning and upcasters are the recovery path for payload evolution; the contract manifest prevents accidental breaking changes. - The Admin portal can report stored event type names the runtime cannot resolve to a registered event type (`GET /admin/api/aggregates/{aggregateType}/{aggregateId}/events/unknown`, opt-in via `ViewUnknownEvents`). Legacy event types handled only by an upcaster may appear in this report because they are not registered current event types. --- # Solution Design Guide This guide helps application developers design an event-sourced solution before writing aggregate code. It is written for Purview EventSourcing projects that use `AggregateBase`, source-generated aggregate events, provider event stores, and optional queryable snapshots. Use it in this order: 1. Model the business process on paper. 2. Choose aggregate boundaries and relationships. 3. Name aggregates, commands, events, and value objects using the repository rules. 4. Decide where validation belongs. 5. Sketch the event stream and read/query model. 6. Only then create aggregate code and tests. For a printable template, use [Solution Design Worksheet](../solution-design-worksheet/). ## Design Principles - An aggregate is a consistency boundary, not a database table. - An event is a fact that has happened, not an instruction to do something. - Aggregate state is derived from its ordered event stream. - Snapshots and query stores are optimizations/read models. The event stream remains the source of truth. - Cross-aggregate workflows should be coordinated by services/process managers, not by loading other aggregates inside an aggregate method. - Relational data belongs in query models, snapshots, projections, or referenced IDs, not as live joins inside aggregate invariants. - Value objects carry reusable meaning and validation across aggregates. - Validation should be explicit about when it runs: command-time, event creation, replay/hydration, save-time, or projection-time. - Correlation IDs, idempotency markers, and transaction boundaries are part of the design, not just infrastructure details. ## Repository Rules To Design Against ### Aggregate Naming Aggregate classes should end with `Aggregate`. ```csharp public sealed partial class OrderAggregate : AggregateBase { } ``` `AggregateBase` derives the persisted aggregate type by trimming the `Aggregate` suffix and converting the remaining type name to lower kebab case: | Class name | Aggregate type | | --- | --- | | `OrderAggregate` | `order` | | `CustomerAggregate` | `customer` | | `LearningHTMLTestAggregate` | `learning-html-test` | The aggregate type is used by store implementations for stream grouping and lookup. Treat it as persisted data. Renaming an aggregate class or overriding the aggregate type after data exists is a migration decision. The source generator supports aggregates that: - are `partial` - have no declared base class, where the generator adds `AggregateBase` - directly inherit `AggregateBase` - transitively inherit through a custom base class If an aggregate uses a custom base class, confirm the chosen base class still inherits `AggregateBase` and does not hide event-sourcing behavior from developers. Aggregates with no declared base class get `AggregateBase` added by the generator. ### Event Naming Generated event type names come from `[Event]` method names unless overridden with `EventName`. Generated events normally end with `Event`. The event store name mapper trims that suffix and stores the event name as: ```text {aggregate-type}.{event-name-without-event-suffix} ``` Example: | Aggregate | Generated event type | Persisted event name | | --- | --- | --- | | `OrderAggregate` | `OrderCreatedEvent` | `order.order-created` | | `CustomerAggregate` | `EmailChangedEvent` | `customer.email-changed` | | `InventoryAggregate` | `StockReservedEvent` | `inventory.stock-reserved` | Prefer past-tense event names: - `OrderCreated` - `CustomerRegistered` - `EmailChanged` - `StockReserved` - `ReservationReleased` - `OrderCancelled` Avoid command-like event names: - `CreateOrder` - `ChangeEmail` - `ReserveStock` - `ValidateCustomer` If the generated name is not the business language you want to persist, set it explicitly: ```csharp [Event(EventName = "CustomerRegistered")] public partial CustomerAggregate RegisterCustomer(string name, string email); ``` Use explicit `EventName` sparingly. It is useful for compatibility, integration contracts, or a domain term the generator cannot infer. Once persisted, event names are contracts. ### Event Namespace By default, generated event classes are placed under: ```text {AggregateNamespace}.{AggregateNameWithoutAggregateSuffix}Events ``` For example, `Purview.EventSourcing.Samples.Domain.OrderAggregate` generates events in an `OrderEvents` namespace. You can override the namespace at aggregate or method level with `EventNamespace`, but use that only when you need stable compatibility or a shared event namespace. ### Generated Method Shapes Use generated methods for state changes that should become events: ```csharp [Event] public partial OrderAggregate CreateOrder(CustomerId customerId); ``` Keep a public wrapper when the business intent needs guard clauses, calculations, or multiple lower-level event methods: ```csharp public OrderAggregate ConfirmOrder() => SetStatusCode(OrderStatusCode.Confirmed); [Event] private partial OrderAggregate SetStatusCode(OrderStatusCode status); ``` Use collection events for `EventStoreList` and `EventStoreSet` properties: ```csharp public EventStoreSet RelatedProjects { get; private set; } = []; [CollectionEvent(nameof(RelatedProjects))] public partial ReportUploadAggregate AddRelatedProject(ProjectId projectId); ``` Use `[Computed]` for deterministic values that callers must not supply directly and that generated hooks finalize before recording the event. Use `Manual = true` when generated property mapping is not expressive enough and you will write the `Apply(...)` method yourself. ## Paper-First Worksheet Copy these tables into a design note or pull request before building a new feature. ### Business Capability | Question | Answer | | --- | --- | | What business process is this? | | | Who initiates it? | | | What decisions must be consistent immediately? | | | What can be eventually consistent? | | | What external systems or UI screens need to know? | | | What audit questions must be answerable later? | | ### Aggregate Candidates | Candidate aggregate | Owns these decisions | Does not own | Lifecycle start | Lifecycle end | | --- | --- | --- | --- | --- | | | | | | | Choose an aggregate when it owns rules that must be consistent in one event stream. Do not create one aggregate per relational table by default. ### Command And Event Sketch | User/system intent | Aggregate method | Event fact | State changed | Validation needed | | --- | --- | --- | --- | --- | | Place order | `CreateOrder(...)` | `OrderCreated` | `CustomerId`, `Status` | Customer ID present | | Add item | `AddLineItem(...)` | `LineItemAdded` or `LineItemsChanged` | `LineItems`, `TotalAmount` | Quantity, price, status | | Confirm order | `ConfirmOrder()` | `OrderConfirmed` | `Status` | Has line items | Keep method names intention-focused. Keep event names factual and past tense. ### Event Stream Sketch | Version | Event | Important payload | Why this event exists | | --- | --- | --- | --- | | 1 | `OrderCreated` | `customerId` | Starts the order lifecycle | | 2 | `LineItemAdded` | `productId`, `quantity`, `unitPrice` | Audits basket change | | 3 | `OrderConfirmed` | `status` | Locks in the order | Check that replaying the events in order recreates the aggregate state without calling external services. ### Relationship Sketch | Relationship | Store on event/aggregate as | Enforce where | Query shape | | --- | --- | --- | --- | | Order belongs to customer | `CustomerId` value object/string | Application service or command validator | Projection joins customer snapshot | | Order reserves inventory | `OrderId`, `ProductId`, `LocationId` | Workflow service across aggregates | Stock reservation read model | | Report belongs to project | `ProjectId` value object | Command-time check or policy | Project report projection | ## Relational Data Event-sourced aggregates should model relationships by identity, not by live object references. Use this pattern inside aggregates: ```csharp public CustomerId CustomerId { get; private set; } public EventStoreList LineItems { get; private set; } = new(); ``` Avoid this inside aggregates: ```csharp public CustomerAggregate Customer { get; private set; } public List Inventory { get; private set; } ``` ### When You Need Relational Views Use query-side models for relational questions: - customer profile with recent orders - inventory by location and product - order details with customer and shipment data - audit pages across aggregate types The project supports queryable snapshot stores for providers such as SQL Server, MongoDB, and Cosmos DB, and a null queryable store for core-only scenarios. Design relational views as projections/snapshots that can be rebuilt from event streams when possible. When designing snapshot-backed SQL queries, distinguish between: - provider-converted scalar value objects, which are ideal for invariants and serialization but may not support deep translation through `.Value`, and - directly mapped complex snapshot members, which can support deep JSON-path predicates when the payload shape is explicitly supported and covered by integration tests. If deep snapshot filtering is a hard requirement for a complex concept, model that query-facing shape deliberately instead of assuming a scalar wrapper will remain queryable. The current repository also includes an in-memory provider and quick-start sample. Treat in-memory storage as a development/testing convenience unless a production use case has explicitly accepted its durability limits. ### Cross-Aggregate Rules If a rule needs more than one aggregate, do not hide that rule inside one aggregate. Use an application service or process manager: ```csharp public sealed class CartCheckoutService(IEventStore eventStore) { public async Task CheckoutAsync(string customerId, CartItem[] items, CancellationToken cancellationToken) { var order = await eventStore.CreateAsync( aggregateId: Guid.NewGuid().ToString(), cancellationToken: cancellationToken); order.CreateOrder(customerId); foreach (var item in items) order.AddLineItem(item.ProductId, item.ProductName, item.Quantity, item.UnitPrice); await eventStore.SaveAsync(order, cancellationToken); } } ``` If several aggregates must be saved together, use the transaction support provided by the selected store where available. Still design each aggregate as if it can replay independently. `EventStoreTransaction` chooses the strongest compatible coordinator available: - If all enlisted stores share a native transaction boundary, commits are atomic. - If no shared native boundary exists, commits are sequential under a shared correlation ID. - Sequential fallback does not roll back aggregates that were already persisted. Design cross-aggregate workflows with this distinction in mind. For mixed stores or unsupported transaction boundaries, use idempotent commands, compensating events, and retry-safe process managers. ## Value Objects Use value objects for concepts that are more meaningful than primitive strings, integers, or decimals: - `EmailAddress` - `Name` - `CustomerId` - `ProjectId` - `Money` - `OrderStatus` - `CurrencyCode` Value objects are a good place for normalization and validation that should apply everywhere. ```csharp [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) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Email address cannot be empty.", nameof(value)); } } ``` Use scalar value objects when one primitive value carries the meaning. Use full value objects when the concept has multiple fields, such as `Money` with `Amount` and `Currency`. Generated value objects distinguish strict creation from hydration: - `Create(...)` normalizes and validates command-time input. - `Hydrate(...)` rebuilds persisted state and should be replay-safe. - `[Scalar]` and `[ValueObject]` default to hydration-oriented deserialization. - `GenerateEmpty`, implicit primitive conversion, comparison operators, JSON converters, and constructor generation are configurable. ### Contextual Value Objects Use contextual value objects when validity depends on the current aggregate state. The sample `OrderStatus` validates allowed transitions against the current `OrderAggregate`. This is useful for: - state machines - date ranges relative to aggregate state - limits that depend on current totals - transitions that must not be checked again during replay Design rule: command-time creation can be strict; replay/hydration must be able to rebuild historical state. ## Validation Across The Board Use the smallest validation scope that correctly owns the rule. | Rule type | Best location | Runs during replay? | Example | | --- | --- | --- | --- | | Primitive shape | Value object | Usually hydrate-safe | Email format, non-empty name | | Command input | Public aggregate method | No | Quantity must be positive | | Property normalization | `OnChanging` hook | No | Trim/lowercase email | | State transition | Contextual value object or aggregate method | No for strict command path | Draft to Confirmed only | | State mutation | Generated/manual `Apply(...)` | Yes | Set `Status`, update totals | | Whole aggregate validity | DataAnnotations default validator or FluentValidation `IValidator` at save | Save-time | Snapshot must be internally valid | | Cross-aggregate rule | Application service/process manager | No | Customer must exist before order | | Read model rule | Projection/query model | Projection-time | Denormalized search fields | ### Aggregate Method Guards Public aggregate methods should protect business intent before raising events: ```csharp public InventoryAggregate ReserveStock(int quantity, string? orderId) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); if (quantity > AvailableQuantity) throw new InvalidOperationException( $"Cannot reserve {quantity} units. Only {AvailableQuantity} available."); return ReserveStock( quantityOnHand: QuantityOnHand, reservedQuantity: ReservedQuantity + quantity, orderId); } ``` ### Generator Hooks Use generated hooks for local normalization and event-specific extension points: - `OnChanging(ref value)` runs before event creation on the command path. - `OnChanged(previous, current)` runs in `Apply(...)`, including replay. - `OnRaisingEvent(ref ...)` runs before the generated event is recorded. - `OnRaisedEvent(@event)` runs after event creation. - `OnAppliedEvent(@event)` runs after application. - `OnShouldApplyEvent(@event, ref bool shouldApply)` can skip generated application. Because `OnChanged` runs during replay, keep it deterministic and free of external side effects. ### Save-Time Validation Stores run aggregate validation before persistence. With no custom validator, the current implementation uses `DefaultAggregateValidator`, which validates standard DataAnnotations such as `[Range]`. Store constructors accept `IAggregateValidator?` — when null, the default DataAnnotations validator is used. FluentValidation integration is available in the separate `Purview.EventSourcing.Validation.FluentValidation` package, which provides `FluentValidationAggregateValidator` to adapt `FluentValidation.IValidator` to `IAggregateValidator`. Register it via `AddFluentValidationAdapter()` or `AddFluentValidationAdapter()` DI extensions. Use save-time validation for aggregate-wide consistency checks that should pass before persistence: ```csharp public sealed class OrderAggregate : AggregateBase { [Range(0, double.MaxValue)] public decimal TotalAmount { get; private set; } } ``` Do not rely only on save-time validation for user-facing command errors. Put business guard clauses near the command method as well so invalid operations fail before an event is created. `SaveResult` carries `Saved`, `Skipped`, and `ValidationResult`. Check `IsValid` or call `EnsureValid()` when callers need validation failures surfaced as exceptions. ## Implementation Pattern Prefer this aggregate shape: ```csharp [Aggregate] public sealed partial class OrderAggregate : AggregateBase { public CustomerId CustomerId { get; private set; } public OrderStatus Status { get; private set; } = OrderStatus.Draft; public EventStoreList LineItems { get; private set; } = new(); public decimal TotalAmount { get; private set; } public OrderAggregate ConfirmOrder() => SetStatusCode(OrderStatusCode.Confirmed); public OrderAggregate AddLineItem(string productId, string productName, int quantity, decimal unitPrice) { ArgumentException.ThrowIfNullOrWhiteSpace(productId); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); ArgumentOutOfRangeException.ThrowIfNegative(unitPrice); var updated = LineItems.Append(new OrderLineItem(productId, productName, quantity, unitPrice)).ToList(); return AddLineItem( new EventStoreList(updated), totalAmount: updated.Sum(m => m.Quantity * m.UnitPrice)); } [Event(EventName = "OrderCreated")] public partial OrderAggregate CreateOrder(CustomerId customerId); [Event(EventName = "OrderLineItemsChanged")] private partial OrderAggregate AddLineItem(EventStoreList lineItems, decimal totalAmount); [Event(EventName = "OrderStatusChanged")] private partial OrderAggregate SetStatusCode(OrderStatusCode status); } ``` The pattern is: 1. Public methods express business intent and validate inputs. 2. Private generated methods record the factual state change when callers should not raise it directly. 3. Events carry enough data to replay state. 4. Value objects normalize and validate reusable concepts. 5. Services coordinate multiple aggregates. When a state change should only be recorded if a property actually changes, a manual aggregate can use `CompareRecordAndApply(...)`; generated aggregate methods already provide the higher-level convention for most cases. ## Event Payload Design Put enough information on the event to replay the aggregate without external reads. Good payloads: - stable IDs - normalized value objects - values needed to update aggregate state - metadata that explains the operation, such as `orderId` on stock reservations - timestamps when the business time differs from event commit time Be careful with: - personally identifiable information - large documents or binary payloads - fields copied from another aggregate that may become stale - values that can be calculated deterministically from event payload For metadata parameters that should be stored on generated events but not mapped to aggregate properties, use `[Metadata]`. ```csharp [Event] public partial InventoryAggregate ReserveStock( int quantityOnHand, int reservedQuantity, [Metadata] string? orderId); ``` Use `[Property(nameof(Property))]` when the parameter name does not match the aggregate property. ```csharp [Event] public partial InventoryAggregate Create( string productId, [Property(nameof(QuantityOnHand))] int initialQuantity = 0); ``` ## Schema Evolution Events are persisted facts. Changing them is a compatibility decision. - Add optional event properties when possible. - Avoid changing the meaning of an existing property. - Use `[Event(Version = N)]` for breaking schema versions. - Add upcasters when old events need to hydrate into newer event shapes. - Do not change generated event/aggregate naming conventions after data exists unless you plan a migration. (`EventSuffixLength` is a provider option that controls the zero-padded numeric suffix on event row IDs, not a naming convention.) - Treat aggregate type names and event names as persisted contracts. ## Operation Semantics Document these choices for each workflow: | Concern | Design decision | | --- | --- | | Aggregate ID | Who creates it: caller, `IAggregateIdFactory`, or store default? | | Correlation ID | How is it propagated across service calls and transactions? | | Idempotency | Should `UseIdempotencyMarker` be enabled for retries? | | Principal ID | Will saves require a `ClaimsPrincipal` identifier? | | Delete behavior | Soft delete, restore, or permanent delete? | | Snapshot behavior | Use snapshots, skip snapshots for replay, or apply operation-specific snapshot strategy? | | Notifications | Which change feed notifications should fire? | These details affect reliability and auditability as much as aggregate code does. ## Testing Checklist For each aggregate: - Can version 1 of the stream be created with one clear lifecycle-start event? - Does every public method either raise an event or intentionally no-op? - Do invalid commands fail before an event is recorded? - Does replaying the event stream rebuild the same state? - Are value object rules tested independently? - Are state-machine transitions tested for allowed and rejected paths? - Are cross-aggregate workflows tested at service level? - Are query/projection shapes tested separately from aggregate behavior? ## Design Review Checklist Before coding, reviewers should be able to answer yes to these: - Aggregate names end in `Aggregate` and produce stable aggregate type names. - Event names are past-tense business facts. - Event payloads can replay state without external services. - Relationships use IDs/value objects inside events and aggregates. - Relational screens are designed as query models, snapshots, or projections. - Validation is assigned to the right layer. - Replay-safe code has no external side effects. - Schema evolution and event versioning have been considered. - Tests cover command guards, replay, value objects, and workflows. --- # Solution Design Worksheet Use this worksheet before implementing a new aggregate, workflow, or read model. Keep the first version short. The goal is to expose modelling decisions early, not to produce perfect documentation. ## Business Capability | Question | Answer | | --- | --- | | Capability or workflow name | | | Primary actor or system | | | Business outcome | | | Immediate consistency decisions | | | Eventually consistent decisions | | | External systems involved | | | Audit questions to answer later | | ## Aggregate Boundaries | Candidate aggregate | Owns these rules | Must not own | Lifecycle starts when | Lifecycle ends when | | --- | --- | --- | --- | --- | | | | | | | | | | | | | Boundary checks: - Can this aggregate make its decision using only its own current state? - Does it need one ordered event stream to stay correct? - Are other aggregate IDs enough, or are you trying to join live state? - Would splitting this aggregate allow invalid business states? ## Names | Concept | Name | Persisted name or note | | --- | --- | --- | | Aggregate class | `ExampleAggregate` | Aggregate type becomes `example` | | Lifecycle-start event | | | | Important transition event | | | | Important correction event | | | | Value object | | | | Query model/projection | | | Naming checks: - Aggregate class ends in `Aggregate`. - Generated event names should read as past-tense facts. - Explicit `EventName` overrides are reserved for compatibility, integration contracts, or deliberate domain language. - Events avoid command-style phrasing unless the business fact genuinely uses that language. - Persisted names are stable enough to keep after release. ## Commands And Events | Intent | Aggregate method | Event fact | Payload | Validation | | --- | --- | --- | --- | --- | | | | | | | | | | | | | | | | | | | Checks: - Invalid commands fail before an event is recorded. - Event payload can replay state without external services. - Metadata is marked with `[Metadata]` when it should not map to aggregate state. - Parameter aliases use `[Property(nameof(Property))]`. - Deterministic generated values use `[Computed]`. - Collection changes use `[CollectionEvent]` with `EventStoreList` or `EventStoreSet`. - Manual events identify who owns the `Apply(...)` method. ## Event Stream Example | Version | Event | Payload summary | Resulting aggregate state | | --- | --- | --- | --- | | 1 | | | | | 2 | | | | | 3 | | | | | 4 | | | | Replay checks: - Replaying these events in order recreates the expected state. - Replay does not call external systems. - Replay does not depend on current time, random values, or database lookups. ## Relationships | Relationship | Stored identity/value object | Enforced by | Read/query model | | --- | --- | --- | --- | | | | | | | | | | | Relationship checks: - Aggregates store IDs/value objects, not other aggregate instances. - Cross-aggregate rules live in an application service, process manager, or transaction boundary. - Relational screens are supplied by snapshots, projections, or query models. - If a workflow spans stores or transaction boundaries, compensating behavior is designed. ## Value Objects | Value object | Primitive fields | Normalization | Validation | Context needed? | | --- | --- | --- | --- | --- | | | | | | | | | | | | | Checks: - Reusable primitive rules are not duplicated across aggregates. - Contextual value objects separate strict command-time creation from hydration/replay. - Value object names use business language. ## Validation Map | Rule | Layer | Failure message/user impact | Test case | | --- | --- | --- | --- | | | Value object | | | | | Public aggregate method | | | | | Generator hook | | | | | DataAnnotations/save-time | | | | | FluentValidation/save-time | | | | | Application service/process manager | | | | | Projection/query model | | | Validation checks: - Command errors fail before events are recorded. - Save-time validation covers whole-aggregate consistency. - Replay/hydration paths do not reject historical facts that were valid when written. - Validation failures are surfaced from `SaveResult` where needed. ## Operation Semantics | Concern | Decision | | --- | --- | | Aggregate ID source | | | Correlation ID propagation | | | Idempotency marker usage | | | Principal/claim requirement | | | Delete/restore/permanent-delete behavior | | | Snapshot strategy | | | Change feed notifications | | | Transaction boundary | | Transaction checks: - Shared native transaction boundary is identified where atomicity is required. - Sequential fallback is acceptable or mitigated. - Retry and compensation behavior is documented. ## Query And Reporting | Question/screen/API | Source events or snapshots | Shape | Rebuild strategy | | --- | --- | --- | --- | | | | | | | | | | | Checks: - The aggregate is not shaped around a single UI screen. - Query models can be rebuilt or corrected from event history where practical. - Sensitive data and retention concerns are noted. - Snapshots are treated as read/performance models, not as the canonical source of truth. ## Schema Evolution | Event | Expected future change | Compatibility approach | | --- | --- | --- | | | | | | | | | Checks: - Event versioning is considered for breaking payload changes. - Old event names and aggregate type names are treated as persisted contracts. - Upcasting or migration notes exist for released event shapes. ## Ready To Build - Aggregate boundary is clear. - Event names and payloads are agreed. - Relationship modelling uses identities and query models. - Validation has a named owner at each layer. - Operation semantics are documented. - Replay path is deterministic. - Tests are identified for commands, replay, value objects, workflows, and projections. --- # Provider Feature Matrix This page summarizes feature availability by package so provider selection is explicit and accurate. The capability values below are backed by executable capability definitions registered by each provider and asserted by the `Capabilities.UnitTests` contract suite — see [Provider Capabilities](../provider-capabilities/) for the runtime-queryable contract and the exact per-registration values. | Capability | `Purview.EventSourcing` (core) | `Purview.EventSourcing.SqlServer` | `Purview.EventSourcing.Postgres` | `Purview.EventSourcing.AzureStorage` | `Purview.EventSourcing.MongoDB` | `Purview.EventSourcing.CosmosDb` | | --- | --- | --- | --- | --- | --- | --- | | Aggregate/event abstractions (`AggregateBase`, `[EventContract]` event records) | Yes | Uses core | Uses core | Uses core | Uses core | Uses core | | Provider-agnostic event facade (`IEventStore`) | Yes | SQL event store | PostgreSQL event store | Azure Table event store | MongoDB event store | Optional registration via snapshot provider | | Provider-agnostic query facade (`IQueryableEventStore`) | Yes (interface + null implementation) | Optional SQL snapshot store | Optional PostgreSQL snapshot store | Not provided | Optional MongoDB snapshot store | Optional Cosmos snapshot store | | Event-stream persistence | Not persistent by itself | Yes | Yes | Yes | Yes | No | | Snapshot-backed query/list/count | Null provider only | Optional | Optional | No | Optional | Optional | | Blob-backed snapshots / large payloads | No | No | No | Yes | No | No | | Provider-neutral transaction guarantee | Explicit `BestEffort` or required `Atomic` | Atomic within one database boundary | Atomic within one database boundary | Best effort | Best effort | Best effort | | Transactional outbox (atomic with events) | No | Yes (`AddSqlServerOutbox`) | Yes (`AddPostgresOutbox`) | No | No | No | | Provider-specific native transaction factory | No | `ISqlServerEventStoreTransactionFactory` | `IPostgresEventStoreTransactionFactory` | No | No | No | | Runtime-configured JSON payload indexes | No | Yes (event + snapshot stores, auto-create path) | Yes (GIN + expression indexes on event + snapshot stores) | No | No | No | | DI registration helpers | `AddNullQueryableEventStore()` | `AddSqlServerEventStore()`, `AddSqlServerSnapshotQueryableEventStore()` | `AddPostgresEventStore()`, `AddPostgresSnapshotQueryableEventStore()` | `AddAzureStorageEventStore()` | `AddMongoDBEventStore()`, `AddMongoDBSnapshotQueryableEventStore()` | `AddCosmosDbSnapshotQueryableEventStore()` | ## Selection guidance - Choose **SQL Server** when you need event streams and optional SQL query snapshots with SQL-native transaction coordination. - Choose **PostgreSQL** when you need append-only PostgreSQL event streams, replay snapshots for strategy-driven rehydration, and optionally a PostgreSQL-backed query store. - Choose **Azure Storage** when you want Azure Table event persistence with Blob support for large payloads/snapshots. - Choose **MongoDB** when you want both event and snapshot stores on MongoDB. - Choose **Cosmos DB** when you only need a queryable snapshot store. ## High-scale / global-production readiness The framework is designed for **single-region, single-authoritative-store** deployment with per-aggregate-stream concurrency. This is a sound model for horizontal scale: writes to different aggregates never contend, and ordering is guaranteed per stream. The following capabilities are built in: - **Keyset event-history paging.** `GetEventHistoryAsync` continuation tokens record the last returned aggregate version, so each page scans only the events it needs (O(page) rather than O(stream)). Legacy integer tokens remain supported. - **Snapshot cache single-flight.** Concurrent first-reads of a cold aggregate serialize rehydration per stream and double-check the cache, preventing replay and cache-write stampedes. Optional `EventStoreOperationContext.ValidateCachedSnapshot` rejects stale cache entries against the stream version (adds one storage read per cache hit). - **Strategy-gated snapshots.** The SQL Server/PostgreSQL same-table snapshot honors `ISnapshotStrategy` (defaults to every save, preserving historical behavior) and per-operation overrides via `SetSnapshotStrategy`, so write amplification can be tuned. - **Concurrency retry + in-process serialization.** `ConcurrencyRetry.ExecuteAsync` retries conflicts (all provider `ConcurrencyException` types implement `IConcurrencyConflict`) with exponential backoff; `AggregateWriteLock` serializes read-modify-write work per stream within a process. - **Conflict recognition across providers.** MongoDB duplicate-key writes now surface as `ConcurrencyException` (not `CommitException`), and the in-memory store throws on conflicting versions instead of silently dropping them. - **Partial-replay detection.** `AggregateBase.SkippedEvents` reports events skipped during replay so callers can detect a partially reconstructed aggregate in a mixed-version fleet. `SkippedEvents` is replay-transient metadata and is **not** persisted in SQL Server/PostgreSQL EF-backed snapshot payloads; snapshot reads reconstruct stored aggregate state without replay and therefore never report skips. Check `SkippedEvents` after event-stream loads rather than relying on snapshot persistence. ### Remaining gaps for global scale (not implemented) - **Geo-replication / multi-region writes.** There is no framework-level replication, multi-region write path, or conflict resolution. Active-active writes across regions are unsupported; route all writes for a stream to one region or use provider-native replication. - **Hot-partition mitigation / sharding.** A stream is a single aggregate instance; a hot aggregate concentrates onto one partition in every provider. SQL Server per-aggregate-type table/schema overrides and provider-native partitioning are the available levers. - **Snapshot query listing paging is offset-based.** Queryable store `ListAsync`/`QueryAsync` continuation is an integer skip; deep pages are O(n). Keyset conversion for arbitrary `orderBy` clauses is not implemented. - **Event-stream aggregate listing is an unbounded scan.** `GetAggregateIdsAsync` streams ids in deterministic (ordered) form but does not support keyset resumption through the API. See the provider guides for provider-specific scaling configuration (for example SQL Server data compression, JSON indexes, and per-type schema overrides). ## Snapshot model reminder - Event-store snapshots are replay/rehydration optimizations for append-only streams. - Query snapshots are explicit read/query stores used through `IQueryableEventStore`. - Applications may use the event store without any query snapshot store at all. - Applications may also pair one event-store provider with a different query-store provider when that better fits read requirements. ### SQL Server query translation notes - SQL snapshot queries support predicates over JSON-mapped primitive members and supported value-object shapes. - SQL Server can additionally create runtime-managed indexes over supported JSON scalar paths when `JsonIndexOptions.Enabled = true` and `AutoCreateTable = true`. - For provider-converted members (for example, a `[Scalar]` value object whose inner `Value` is a complex type), deep predicates on inner members are **not SQL-translatable** (for example: `a.ReportSummary.Value.ParserDetails.FailedLines > 0`). - The same conceptual data **can** be queried deeply when exposed as a directly mapped complex property in the snapshot graph (for example: `a.ReportSummaryScalar.ParserDetails.FailedLines > 0`, where `ReportSummaryScalar` is a `ParserReportSummary`). - `EventStoreList` / `EventStoreSet` members with `[ValueObject]` struct elements are persisted via JSON conversion for compatibility; treat nested element member filtering as non-translatable unless explicitly covered by tests. - Nested dictionary/interface-collection members cannot be structurally mapped by the SQL Server or PostgreSQL EF snapshot model. Mark non-queryable values `[EFOpaque]` to persist them as a converted JSON scalar, or remodel them as complex entry collections when their contents must be queried. - Opaque JSON currently uses EF's supported string conversion inside the outer JSON document. This preserves round-trip values but stores the nested value as JSON text rather than a raw nested JSON token. - Recommended pattern: query by SQL-translatable fields first, or expose a directly mapped complex mirror property when deep SQL filtering is a real requirement. ## Related docs - [Getting Started](../) - [Guarantees and Limitations](../guarantees-and-limitations/) - [Provider Capabilities](../provider-capabilities/) - [Dependency Guardrails](../dependency-guardrails/) - [SQL Server Guide](../sql-server-guide/) - Postgres package README: `src/src/Postgres/Sdk/README.md` - Core package README: `src/src/EventSourcing/Sdk/README.md` - SQL Server package README: `src/src/SqlServer/Sdk/README.md` - Azure Storage package README: `src/src/AzureStorage/Sdk/README.md` - MongoDB package README: `src/src/MongoDB/Sdk/README.md` - Cosmos DB package README: `src/src/CosmosDb/Sdk/README.md` --- # Provider Capabilities Event-store capabilities are exposed as a provider-neutral, queryable contract so applications and Admin tooling can determine actual guarantees instead of inferring them from a provider name. ## Discovery Resolve `IEventStoreCapabilitiesProvider` from dependency injection: ```csharp public sealed class StoreHealth(IEventStoreCapabilitiesProvider capabilitiesProvider) { public void Report() { var capabilities = capabilitiesProvider.GetCapabilities(); var guarantee = capabilities.TransactionGuarantee; var preservesMetadata = capabilities.PreservedMetadata; } } ``` Capability discovery never constructs a store or probes live storage; it only reads what was registered. `IEventStoreCapabilitiesProvider` is always resolvable after `AddEventSourcing()` and reports the conservative `EventStoreCapabilities.Default` until a provider registers its capabilities. ## What is exposed | Member | Meaning | | --- | --- | | `TransactionGuarantee` | `EventStoreTransactionGuarantee.Atomic` or `.BestEffort` (the same abstraction used by transaction options). | | `SupportsEventStreams` | Whether the provider persists an append-only event stream. | | `SupportsSnapshots` | Whether the provider stores aggregate snapshots (replay cache or query store). | | `SnapshotSchemaVersioning` | `None`, `SingleVersion` (legacy single-shape layout), or `Versioned` (honors `[SnapshotSchemaVersion]`). | | `PreservedMetadata` | Flags for which event metadata fields are persisted: `SchemaVersion`, `CorrelationId`, `CausationId`, `UserId`, `IdempotencyId`, `AggregateVersion`, `When`. | | `SupportsQueries` | Whether a queryable snapshot store is available through `IQueryableEventStore`. | | `SupportsIdempotencyMarkers` | Whether saves deduplicate on an idempotency marker. | | `Concurrency` | `Optimistic` (conflicts rejected) or `LastWriterWins`. | | `OperationalLimitations` | Stable limitation identifiers, for example `non-persistent` (InMemory) and `no-event-stream` (Cosmos DB). | ## Registration Built-in providers register their truthful capabilities from their `Add*EventStore` extension methods. Multiple registrations for the same provider are merged into the union of what is actually available (for example SQL Server event store + snapshot query store report atomic transactions, event streams, snapshots, and queries). Custom providers register their own capabilities explicitly: ```csharp services.AddEventStoreCapabilities(new EventStoreCapabilities( EventStoreTransactionGuarantee.BestEffort, SupportsEventStreams: true, SupportsSnapshots: false, SnapshotSchemaVersioning: SnapshotSchemaSupport.None, PreservedMetadata: PreservedEventMetadata.All, SupportsQueries: false, SupportsIdempotencyMarkers: false, Concurrency: ConcurrencyGuarantee.Optimistic, OperationalLimitations: [] )); ``` Providers that register nothing are reported with `EventStoreCapabilities.Default`: best-effort transactions, no streams, no snapshots, no queries, no idempotency, and `LastWriterWins` concurrency. A provider is never assumed to offer stronger behavior than it implements. ## Built-in capabilities The values below are asserted by the `Capabilities.UnitTests` contract suite so documentation and implementation cannot drift apart. | Provider | Transactions | Event streams | Snapshots | Snapshot versions | Metadata | Queries | Idempotency | Concurrency | Transactional outbox | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | InMemory event store | BestEffort | Yes | No | None | All | No | Yes | Optimistic | No | | InMemory snapshot store | BestEffort | Yes | Yes | SingleVersion | All | Yes | Yes | Optimistic | No | | SQL Server event store | Atomic | Yes | Yes | Versioned | All | No | Yes | Optimistic | Yes | | SQL Server snapshot query store | Atomic | No | Yes | SingleVersion | None | Yes | No | Optimistic | No | | PostgreSQL event store | Atomic | Yes | Yes | Versioned | All | No | Yes | Optimistic | Yes | | PostgreSQL snapshot query store | Atomic | No | Yes | SingleVersion | None | Yes | No | Optimistic | No | | Azure Storage | BestEffort | Yes | Yes | Versioned | All | No | Yes | Optimistic | No | | MongoDB event store | BestEffort | Yes | Yes | Versioned | All | No | Yes | Optimistic | No | | MongoDB snapshot query store | BestEffort | No | Yes | SingleVersion | None | Yes | No | Optimistic | No | | Cosmos DB snapshot query store | BestEffort | No | Yes | SingleVersion | None | Yes | No | Optimistic | No | The [Provider Feature Matrix](../provider-feature-matrix/) summarizes the same facts for package selection. --- # Transactional Outbox The transactional outbox persists messages atomically with event saves and dispatches them reliably to downstream consumers. It is an **explicit capability**, not a universal guarantee: only providers with a relational transaction boundary (SQL Server and PostgreSQL) can write the outbox in the same native transaction as events. ## Honest semantics An outbox provides **atomic persistence plus at-least-once delivery**: - Atomic persistence means a message committed with its events cannot be lost — if the event save rolls back, the outbox write rolls back too. - Delivery is at-least-once: after a crash or lease expiry a message can be dispatched again. **Consumers must be idempotent.** `EventStoreCapabilities.SupportsTransactionalOutbox` reports which registered providers support the atomic write path; see [Provider Capabilities](../provider-capabilities/). ## Registering ```csharp builder.Services.AddSqlServerOutbox(options => { options.MaxAttempts = 5; options.RetryBackoffBase = TimeSpan.FromSeconds(5); }); ``` PostgreSQL uses `AddPostgresOutbox`. The hosted dispatch loop is registered automatically. Outbox table options are bound from `EventStore:SqlServer:Outbox` (or `EventStore:Postgres:Outbox`) and fall back to the event-store connection string. ## Writing messages atomically with events Use the provider-native transaction coordinator and enlist the outbox write alongside the aggregate save: ```csharp public sealed class OrderService( ISqlServerEventStoreTransactionFactory transactionFactory, ISqlServerEventStore orderStore, SqlServerOutboxStore outboxStore) { public async Task PlaceOrderAsync(OrderAggregate order, CancellationToken cancellationToken) { var envelope = new OutboxEnvelope( Id: Guid.NewGuid().ToString("N"), AggregateType: nameof(OrderAggregate), AggregateId: order.Id(), EventType: "OrderPlaced", PayloadJson: "{\"orderId\":\"" + order.Id() + "\"}", IdempotencyKey: order.Id(), CorrelationId: null, CreatedUtc: DateTimeOffset.UtcNow); await using var transaction = transactionFactory.CreateSqlServerTransaction(); transaction.Enlist(order, orderStore); transaction.Enlist((connection, sqlTransaction, token) => outboxStore.EnqueueInTransactionAsync(connection, sqlTransaction, envelope, token)); var result = await transaction.CommitAsync(cancellationToken); } } ``` Enqueuing with the same `IdempotencyKey` again is a no-op (deduplicated at the store). ## Dispatch behavior - **Leasing/claiming:** a batch is claimed atomically (`UPDATE ... OUTPUT`/`RETURNING`) by a lease owner until a lease duration; another dispatcher reclaims only expired leases. - **Ordering:** messages are claimed oldest-first by `CreatedUtc`, then `Id`. - **Retry and backoff:** failures increment the attempt count and schedule a retry with exponential backoff (`RetryBackoffBase` doubled per attempt, capped at 64x). - **Poison messages:** after `MaxAttempts` the message moves to the poisoned (dead-letter) state with the last error recorded. Poisoned and dispatched messages older than `Retention` are removed by `CleanupAsync`. - **Observability:** every failure and dispatch cycle is logged; the last error is stored on the message. - **Cancellation:** dispatch honors the cancellation token; stopping the host interrupts the loop. ## Concurrent dispatchers Multiple dispatchers (or hosts) may run concurrently. The lease claim is atomic, so each message is claimed by exactly one dispatcher at a time. See `SqlServerOutboxIntegrationTests` and `PostgresOutboxIntegrationTests` for real-provider coverage of atomic commit/rollback, retry/poison, deduplication, and concurrent claim disjointness. ## Dead-letter visibility The Admin portal exposes poisoned (dead-letter) messages at `GET /admin/api/outbox/poisoned` when the `ViewPoisonedOutbox` feature and permission are enabled (opt-in, separately authorized, and audited). `IOutboxStore.GetPoisonedAsync` returns a page of poisoned messages ordered most-recently-poisoned first; providers without dead-letter inspection return an empty page. --- # Transaction Guarantees `IEventStoreTransaction` coordinates aggregate saves, but the exact guarantee depends on the enlisted stores. Callers can inspect `AvailableGuarantee` after enlistment and can require atomicity when creating a transaction. ```csharp await using var transaction = transactionFactory.Create( new EventStoreTransactionOptions { CorrelationId = command.CorrelationId, RequiredGuarantee = EventStoreTransactionGuarantee.Atomic, }); transaction.Enlist(order, eventStore); transaction.Enlist(inventory, eventStore); await transaction.CommitAsync(cancellationToken); ``` When `Atomic` is required, `CommitAsync` validates every enlisted store and its native transaction boundary before performing any write. Incompatible providers, different databases, or stores without a native coordinator cause an `EventStoreTransactionGuaranteeException`; no enlisted aggregate is saved. When `BestEffort` is accepted (the backward-compatible default), the coordinator uses a native atomic transaction when every store shares a supported boundary. Otherwise, it saves sequentially under one correlation ID, stops on the first failure, and does not roll back earlier saves. A correlation ID and idempotency marker aid recovery but do not make sequential saves atomic. | Scenario | Available guarantee | | --- | --- | | SQL Server stores sharing one configured database boundary | `Atomic` | | PostgreSQL stores sharing one configured database boundary | `Atomic` | | Stores using different database boundaries | `BestEffort` | | Any provider without native transaction coordination | `BestEffort` | | Mixed providers | `BestEffort` | Provider-specific SQL transaction factories always require and provide `Atomic`; they reject unsupported stores during enlistment. Provider-neutral transactions make the requirement explicit at creation and verify it again at commit, after all stores have been enlisted. --- # Event Contract Manifest The source generator produces a **deterministic, machine-readable schema manifest** of every generated event contract in a compilation. The manifest is the machine-readable contract that must stay compatible with previously persisted event payloads, and it is the input to baseline-based compatibility validation. ## What is captured For every `[Aggregate]` with at least one valid event method, the manifest records: | Entry | Meaning | | --- | --- | | Aggregate name / namespace | The aggregate type identity | | Event name / namespace / method | The generated event identity and the source method | | Schema version | `[Event(Version = N)]`, defaulting to 1 | | Fields | Each persisted event property: name, fully-qualified type, element type (arrays), array flag, nullability, requiredness (`[Required]`), and string flag | The manifest is deliberately **location-free**: it captures only what affects persisted JSON compatibility, so comments, formatting, and unrelated source edits never change it. ## Determinism guarantees - Stable ordinal ordering for aggregates, events, and fields — reordering source declarations does not change the output. - No timestamps, absolute paths, machine information, random values, reflection-order dependencies, or culture-sensitive formatting. - Identical input always produces byte-identical output. ## Emitting the manifest Emission is opt-in via the MSBuild property: ```xml true ``` With the property set, the generator emits `EventContractManifest.g.cs` (a generated source constant) and the packaged build targets materialize the compact JSON to `EventContractManifest.json` in the project directory after `CoreCompile`. ## Supplying a baseline Add the approved manifest as an additional file so the generator can compare current contracts against it: ```xml ``` The default baseline file name is `EventContractManifest.json`. Override it with: ```xml event-contracts.json ``` Comparison runs whenever a matching additional file is present; without a baseline no compatibility diagnostics are emitted. ## Generate, commit, update, and validate in CI 1. **Generate** — enable `PurviewEventContractManifestEnabled` and build; the target writes `EventContractManifest.json`. 2. **Commit** — commit the generated file as the approved baseline. 3. **Validate** — every build (CI included) compares current contracts against the committed baseline and fails on breaking changes. 4. **Update** — for an intentional, documented schema evolution, bump the schema version (and add an upcaster), then regenerate and commit the updated baseline in the same change. ## Compatible additions versus breaking changes **Silent (compatible):** - Adding a new aggregate or a new event. - Bumping an event's schema version (the sanctioned evolution path). - Adding an optional (nullable, non-`[Required]`) field to an existing event. - Relaxing a field from non-nullable to nullable. **Diagnostics (breaking), reported as errors:** | ID | Condition | | --- | --- | | `EVENTSTORE030` | An aggregate contract was removed or renamed | | `EVENTSTORE031` | An event was removed or renamed | | `EVENTSTORE032` | A persisted field was removed or renamed | | `EVENTSTORE033` | A persisted field type changed incompatibly | | `EVENTSTORE034` | A field became required/non-nullable, or a `[Required]` field was added on an unchanged version | | `EVENTSTORE035` | An event's schema version decreased below the baseline | | `EVENTSTORE036` | The baseline manifest is malformed or uses an unsupported format version | Each diagnostic points at the current method or aggregate declaration and explains the remediation: retain compatibility, bump the schema version and add an upcaster, or introduce a new event type. ## Runtime access and Admin inspection The generated `EventContractManifest` class is public, so applications can register it for runtime inspection: ```csharp builder.Services.AddEventContractManifest( EventContractManifest.FormatVersion, EventContractManifest.Json, baselineJson: /* the committed baseline, when available */); ``` `IEventContractManifestProvider` then reports the manifest and a compatibility status (`Compatible` when the current manifest matches the supplied baseline, `Incompatible` when it differs, `NotConfigured` when no baseline was supplied). The Admin portal exposes it at `GET /admin/api/manifest` when the `ViewManifest` feature and permission are enabled (opt-in, separately authorized, audited). ## Format version The manifest carries a `formatVersion` field. When the generator supports a different format, the baseline is rejected with `EVENTSTORE036` and must be regenerated with the current package. --- # Source Generator Performance The source-generator performance harness measures how fast the aggregate and value-object generators are and how their incremental pipeline behaves. It runs under BenchmarkDotNet (in-process toolchain) in the same `Benchmarks` console project as the runtime and SQL Server suites. ## Running ```text just perf-source-generator # quick run (1 warmup, 3 iterations) just perf-source-generator --benchmark # benchmark run (3 warmup, 12 iterations) ``` Equivalent: `dotnet run --project src/src/Benchmarks/Benchmarks.csproj --configuration Release -- source-generator`. Always use Release; in Debug the JIT produces meaningless numbers. Each run writes a JSON snapshot to `artifacts/source-generator-performance/history/` and the latest to `artifacts/source-generator-performance/latest.json`, then prints a summary compared against the previous run. `artifacts/` is not committed. ## What is measured For every scenario (`AggregateSimple`, `AggregateWithValueObjects`, `AggregateMulti`, `ScalarValueObject`, `ComplexValueObject`) the harness records: | Measurement | Meaning | | --- | --- | | `ColdGeneration` | Fresh compilation and driver, generate once (framework cost floor) | | `WarmRerun` | Incremental rerun of the same driver + compilation | | `SingleAggregateEdit` | Rerun after one aggregate changed in the five-aggregate `AggregateMulti` compilation | Ratios are computed against cold generation and enforced as regression guards, and every case is also compared against the previous run (40% mean regression threshold). ## Known incremental-caching hotspot ## Incremental caching The generator emits an inert **pre-compilation marker** (`RegisterPreCompilationSourceOutput`, experimental `RSEXPERIMENTAL007`) so Roslyn's `CompilationCache` reuses the previous run's compilation reference on an identical rerun. This short-circuits the per-candidate `ForAttributeWithMetadataName` transforms: a **warm rerun measures ~6–12% of cold generation** (the aggregate/value-object targets report exactly `Cached`), so identical incremental builds are effectively free. The harness also captures per-run **step run-reasons** (`Cached`/`Unchanged`/`Modified`/`New` per pipeline stage) to `artifacts/source-generator-performance/steps.txt` and prints them, so a regression that silently regenerates work on warm reruns is visible before the threshold trips. The ratio thresholds are regression guards: warm-rerun must stay at or below **40%** of cold generation (guarding the marker against silently regressing), and single-aggregate-edit at or below **150%** (an edit inherently re-executes the changed aggregate's transform). ## Interpreting history The summary compares each scenario against `latest.json` from the previous run. When reporting a regression, record the machine (`Machine`), framework (`Framework`), mode, and the history file so the comparison conditions are reproducible. Compare runs on the same machine and mode. ## Comparison conditions - All measurements run in-process on the machine where the harness is executed. - The quick mode is for local iteration; the benchmark mode is for recorded comparisons. - Correctness is enforced separately by `SourceGenerator.UnitTests` (step-reason caching tests and byte-identical determinism tests); the performance harness is not a correctness substitute. --- # Runtime Performance The runtime performance suite (`just perf-runtime`) measures the **runtime hot paths of the code the source generator emits**, using the generated aggregates and value objects in `src/src/Samples` (`OrderAggregate`, `CustomerAggregate`, `EmailAddress`, `Money`, `OrderStatus`, `UserDetails`, ...). It runs under BenchmarkDotNet with `[MemoryDiagnoser]`, so every case reports both wall time and allocated bytes per operation. ## Running ```text just perf-runtime # quick run (1 warmup, 3 iterations) just perf-runtime --benchmark # benchmark run (3 warmup, 12 iterations) ``` Equivalent: `dotnet run --project src/src/Benchmarks/Benchmarks.csproj --configuration Release -- runtime`. Always use Release; in Debug the JIT produces meaningless numbers. ## What is measured | Category | Cases | | --- | --- | | Aggregate | `new CustomerAggregate()`, `new OrderAggregate()` (per-instance registration cost) | | Command | generated partial methods: `CreateOrder`, `ConfirmOrder`, `ShipOrder`, `CompleteOrder`, `AddLineItem`, `RegisterCustomer`, `ChangeEmail` | | Replay | single event application, 100-event stream replay, event `GetHashCode` | | ValueObject | scalar (`EmailAddress`, `CurrencyCode`, `OrderStatus`) and complex (`Money`, `UserDetails`) `Create`/`Hydrate`/equality/hash/compare/`ToString`/implicit conversion | | Serialization | event payload round-trip (the reflection-based provider path), snapshot round-trip through the generated `OrderAggregateJsonConverter`, value-object round-trip through generated converters | | Collections | `EventStoreList` add/enumerate, `EventStoreSet` add/contains/remove | | Mapping | `IAggregateEventNameMapper.GetName` per event type | Command and replay cases construct a fresh aggregate per invocation (measured together with the command), which reflects the realistic hot path: an aggregate is loaded or created, then mutated. The delta between `Command_CreateOrder` and `AggregateConstruction_Order` isolates the command cost. ## Interpreting results Each run writes `artifacts/runtime-performance/{history,latest.json}` and compares against the previous run. The suite fails when: - allocated bytes per operation regress by more than 10% for any case, or - mean wall time regresses by more than 40% for any case. Allocations are the most reliable regression signal in a micro-benchmark: an extra event allocation, delegate, or closure per operation shows up immediately as a byte jump. When reporting a regression, record the machine, framework, mode, and the history file (the previous-run comparison is only meaningful on the same machine). ## Known hotspots and findings The suite exists to surface and track these; the numbers below are from a representative run and should be re-measured locally. - **Generated command methods** construct a **single** event record per invocation: the property `OnChanging` hooks run, `OnShouldApply` is evaluated before `OnRaising`, the payload is re-synchronized from post-hook values, and the event is recorded via `RecordAndApply`. A re-introduction of a second event construction shows up as a byte jump. - **Event application/replay is allocation-free**: `Replay_100EventStream` allocates the same bytes as constructing the aggregate, because applying an event is a shared-applier lookup plus a delegate call plus property assignments. - **Aggregate construction is near-zero allocation**: `AggregateBase` builds a per-type static applier map once (open delegates shared across instances), stores unsaved events in a `List<(object, EventMetadata)>`, and derives `AggregateType` from a cached name. With events as `sealed record` payloads carrying a struct `EventMetadata` (no per-event metadata heap object), `new OrderAggregate()` dropped from ~2.1 KB to **~0.4 KB** and `Command_CreateOrder` from ~2.6 KB to **~0.8 KB**. - **Event-name mapping is allocation-free on the hot path**: `AggregateEventNameMapper` caches names by CLR type, so the per-event `Type.AssemblyQualifiedName` string is no longer built on every save (`EventNameMapper_GetName` measures 0 bytes/op). - **Event/snapshot payloads serialize through reflection-based System.Text.Json** (no source-generated `JsonSerializerContext` for events); the generated aggregate/value-object converters are thin wrappers over DTOs and are already reflection-free. Event `Metadata` is `[JsonIgnore]`d, so payloads carry no per-event metadata and provider row columns are the source of truth on replay. - **SQL Server save (SQL suite)**: skipping the guaranteed-miss existence `SELECT` for brand-new stream rows, caching `DbContextOptions`, gating cache-key allocations on `CacheMode`, and folding the snapshot write into the events batch/transaction (atomic by default via `RequireSnapshotWrite`, with a best-effort opt-out) reduced `EventStore_Save` from ~16 ms to **~12 ms**. The `EventStore_Save_NoSnapshot` case isolates the default per-save snapshot (Interval=1) cost; operators can raise the cadence with `operationContext.SetSnapshotStrategy(new IntervalSnapshotStrategy(N))` with no code change. - **In-memory store suite**: `just perf-inmemory` measures the allocation-free reference implementation — save ~10 µs, cached get ~0.3 µs, and a 101-event replay ~35 µs — so provider overhead can be compared against a zero-I/O baseline. The source-generator suite now short-circuits identical reruns via a pre-compilation marker (warm ≈ 6–12% of cold); see `Source-Generator-Performance.md`. --- # Dependency Guardrails This page documents repository guardrails that prevent known dependency/runtime pitfalls. ## Purview.ZodSharp direct-reference guardrail ### Problem When a consumer project directly references the `Purview.EventSourcing.Validation.ZodSharp` project and uses `Purview.ZodSharp` types, relying on transitive package flow can lead to runtime assembly load failures (for example, `FileNotFoundException` for `Purview.ZodSharp`). ### Required fix in consuming project Add a direct package reference: ```xml ``` ### Automated enforcement `Purview.EventSourcing.Validation.ZodSharp` includes a build target in package `buildTransitive` assets (`buildTransitive/Purview.EventSourcing.Validation.ZodSharp.targets`): - Target name: `ValidateZodSharpDirectReference` - Runs: `BeforeTargets="ResolveReferences"` - Behavior: - Detects projects that reference `Purview.EventSourcing.Validation.ZodSharp` via `ProjectReference` - Fails the build if `PackageReference Include="Purview.ZodSharp"` is missing - Emits a remediation message with the exact package reference to add This shifts failure left from runtime to build-time. ### CI verification The reusable pack workflow also validates the generated `.nupkg` and fails if `buildTransitive/Purview.EventSourcing.Validation.ZodSharp.targets` is missing from the package contents. ## Validation adapters overview - `Purview.EventSourcing.Validation.FluentValidation`: adapter for `FluentValidation.IValidator` to `IAggregateValidator`. - `Purview.EventSourcing.Validation.ZodSharp`: adapter for `Purview.ZodSharp` schema validation to `IAggregateValidator`. When using either adapter package directly from source projects, keep direct package references explicit for external runtime dependencies used by the adapter. ## Admin API validation and OpenAPI dependencies `Purview.EventSourcing.Admin.API` validates its request contracts and options with Purview.ZodSharp source-generated schemas and ships the Admin API OpenAPI document (`/openapi/admin.json`) used to generate `Purview.EventSourcing.Admin.Client`. As a result `Purview.ZodSharp`, `Purview.ZodSharp.AspNetCore`, and `Purview.ZodSharp.SystemTextJson` are direct dependencies of the Admin API package. ### OpenAPI XML-comment source generator is disabled in Admin.API The `Microsoft.AspNetCore.OpenApi` package ships a source generator that builds a runtime cache of XML doc IDs across the compilation and its referenced assemblies. Purview's telemetry scaffolding (`Purview.Telemetry.SourceGenerator`) re-declares the same attribute types in every assembly, which makes that cache throw at runtime with a duplicate key when an OpenAPI document is generated. The Admin.API project therefore removes the `Microsoft.AspNetCore.OpenApi.SourceGenerators` analyzer from its compilation (see `Admin.API.csproj`), and the spec-export tool (`src/tools/AdminAPI.OpenAPI`) does not feed referenced assembly XML docs to the generator. The generated Admin API document and typed client remain complete; XML-comment-derived schema descriptions are omitted. --- # Event Versioning Strategy Each persisted event row/document records `SchemaVersion`, `CorrelationId`, `CausationId`, `UserId`, `IdempotencyId`, aggregate version, timestamp, and event name separately from its payload. This allows Admin and history consumers to inspect an event envelope without deserializing sensitive or obsolete payload JSON. Legacy SQL rows are assigned schema version 1 by the metadata migration. Document and table providers also treat a missing schema-version field as version 1. Correlation, causation, and user identifiers remain null when they were not recorded by the original write; they are never inferred during a migration. This document codifies the product-wide approach to event versioning and schema evolution across all Purview EventSourcing providers. ## Core Principles 1. **Events are append-only immutable facts.** Never change the meaning of persisted event data. 2. **SchemaVersion is the versioning contract.** Track breaking payload changes through the `SchemaVersion` on the event contract (a static property on generated events). 3. **Upcasting bridges payload versions.** When old events must hydrate into new event shapes, implement `IEventUpcaster`. 4. **Unknown events fail safely.** Providers return `UnknownEvent` when event types cannot be resolved or deserialized. 5. **Providers implement consistent replay semantics.** Replay-time upcasting is applied uniformly by the stream-backed providers — SQL Server, PostgreSQL, Azure Storage, and MongoDB. The in-memory provider does not apply upcasting. ## Event Contract Shape Events are `[EventContract]` sealed records with no base class or interface. Payload properties are stored directly on the record; framework-managed metadata lives in a `[JsonIgnore]`d `Metadata` property of type `EventMetadata` and is persisted by providers to row columns / document fields and rehydrated on replay. ```csharp [EventContract] public sealed record OrderCreatedEvent { public string OrderId { get; set; } = default!; public string Currency { get; set; } = default!; [JsonIgnore] public EventMetadata Metadata { get; set; } public static int SchemaVersion => 2; } ``` See [Source Generator Behaviors](../source-generator-behaviors/) for the generated shape and naming rules. ## When to Version vs. When to Rename ### Add a new property without versioning (additive change) - Property is **optional** (nullable or has a default). - Backward compatibility is preserved: old events deserialize successfully without the new field. - **Example:** `CustomerRegisteredEvent` gains an optional `PhoneNumber` field; old events hydrate with `null` or `string.Empty`. - **Action:** No `SchemaVersion` bump needed; no upcaster required. ### Increment SchemaVersion (breaking payload change) - Property is **required** and has no safe default (e.g., changes meaning or becomes non-nullable). - Property is **removed or renamed** without a clear mapping. - **Example:** `OrderCreatedEvent` v1 has optional `Currency`; v2 makes it required. Or `Price` → `UnitPrice` with different semantics. - **Action:** Use `[Event(Version = 2)]` and implement an upcaster. ### Create a new event type (semantic change) - The event's **meaning fundamentally changes** (e.g., `UserRegisteredEvent` → `UserRegisteredWithEmailVerificationEvent`). - The domain concept is distinct and should have its own event contract. - **Example:** A new workflow requires user email verification at registration; instead of changing `UserRegisteredEvent`, define `UserRegisteredAndVerificationSentEvent`. - **Action:** Define a new event class. Optionally define an upcaster if the new event should apply the old event's data. ## SchemaVersion Details ### Scope - `SchemaVersion` is per-event-class, not per-aggregate. - Multiple events on one aggregate can have different versions. ### Numbering - Starts at 1 (default). - Increment by 1 for each breaking change. - Never decrease; version numbers are immutable markers. ### Declaration **Via the source generator:** ```csharp [Aggregate] public partial class OrderAggregate : AggregateBase { public string OrderId { get; private set; } = default!; public string Currency { get; private set; } = "USD"; // Added in v2 // Version 2: Currency is now part of the event [Event(Version = 2)] public partial void CreateOrder(string orderId, string currency); } ``` The generator emits a `[EventContract]` record named `OrderCreatedEvent` with `public static int SchemaVersion => 2;`. **Manually (hand-written event contract):** ```csharp [EventContract] public sealed record OrderCreatedEvent { public string OrderId { get; set; } = default!; public string Currency { get; set; } = default!; public static int SchemaVersion => 2; } ``` Hand-written events must be marked `[EventContract]` (the analyzer enforces this with `EVENTSTORE037`) and are registered with `Register(...)` or `RegisterGenerated()`. ## Upcasting Chains ### Purpose Upcasters convert old event payloads (deserialized from storage) into current event shapes so aggregates can apply them during replay. ### Implementation **Single-hop upcaster (v1 → v2):** ```csharp public sealed class OrderCreatedV1ToV2Upcaster : IEventUpcaster { public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) => new() { OrderId = source.OrderId, Currency = "USD", // Default for legacy events }; } ``` Metadata is **not** copied by the upcaster: `EventMetadata` is carried by the framework and re-attached by the store, so the source's metadata flows to the upcast event automatically. **Multi-hop chain (v1 → v2 → v3):** ```csharp // Register both upcasters; the registry applies them in sequence. services.AddEventUpcaster(); services.AddEventUpcaster(); // On replay, events automatically: v1 → v2 → v3 (final) → aggregate.ApplyEvent() ``` ### Upcaster Rules - **Direction:** Forward only (v1 → v2 → v3 → …). Downgrading events is not supported. - **Metadata:** Do **not** copy metadata in an upcaster. The store re-attaches `EventMetadata` (idempotency, correlation, user, timestamp, schema version) to the upcast event. - **Legacy type resolution:** Legacy (source) event types are registered automatically from the upcaster registry when an aggregate is initialized, so stored legacy event names resolve back to CLR types during replay. No extra registration is required. - **Same-type upcasters:** An upcaster whose source and target types are the same (an in-place transform) is applied exactly once; it is not treated as a cycle. - **Cycle detection:** The registry detects and rejects circular upcaster chains (for example v1 → v2 → v1) when it is constructed. - **Unknown target:** If an old event has no upcaster path to a known type, it remains `UnknownEvent`. ### Detecting Partial Replay Because old consumers reading newer events skip what they cannot apply, a replayed aggregate can be **partially stale** without an error being thrown. Replay records every skipped event on the aggregate instance: - `aggregate.SkippedEvents` (`IReadOnlyList`) lists the versions, persisted event names, and whether each was unresolvable (`UnknownEvent`) or simply not applicable. - Callers that must not act on stale state should check `SkippedEvents` after a load and fail closed or rehydrate through a different path when it is non-empty. `SkippedEvents` is populated only while an aggregate is rehydrated from an event stream. It is not persisted in SQL Server/PostgreSQL EF-backed snapshot payloads, so always check it on the aggregate returned by an event-stream load. Downgrading (downcasting newer events into older shapes) remains unsupported; this signal exists so applications can detect and react to the mixed-version-fleet case explicitly. ## Replay Semantics (Stream-Backed Providers) When replaying an aggregate from the event stream: 1. **Deserialize** the event from JSON. If the event type cannot be resolved, return `UnknownEvent`. 2. **Apply upcasting chain** (if a registry is present). Follow all registered upcasters in sequence until no further upcaster is found. 3. **Call `aggregate.ApplyEvent()`** with the (possibly upcast) event. 4. **Handle unknown events** gracefully. The aggregate's `CanApplyEvent()` should return false for `UnknownEvent`; the store logs and continues replay. ### Provider Implementation Checklist - [ ] `GetEventRangeAsync()` applies the upcaster registry after deserializing (SQL Server, PostgreSQL, Azure Storage, MongoDB). - [ ] `GetAsync()` (single aggregate load) applies the upcaster registry during replay. - [ ] Unknown event types return `UnknownEvent` with metadata populated. - [ ] Upcasting errors are logged and surfaced (not silently swallowed). - [ ] Multi-hop upcasting chains are tested end-to-end. ## Documentation and Contracts ### EventMetadata Preservation `EventMetadata` is framework-managed and is **not** copied by upcasters. Providers persist these fields to row columns / document fields and re-attach them on replay: `IdempotencyId`, `AggregateVersion`, `When`, `UserId`, `CausationId`, `CorrelationId`, `SchemaVersion`. ### Event Type Naming - Event type names are persisted as `{aggregate-type}.{event-name-without-event-suffix}` (for example `order.order-created`). Renaming an event type breaks deserialization without a migration step. - If renaming is necessary, define the old event type alongside the new one and create an upcaster. ### Version Boundaries - `SchemaVersion` is persisted as event metadata (a row column / document field), not inside the payload, and is rehydrated into `EventMetadata` on replay. - Consumers can inspect `@event.Metadata.SchemaVersion` to make conditional decisions during replay (fallback values, feature flags, etc.). ## Test Coverage All stream-backed providers must verify: 1. **Additive changes** – Old events deserialize and apply without upcasters. 2. **Versioned events** – New events with `SchemaVersion > 1` deserialize correctly. 3. **Single-hop upcasting** – V1 events are upcast to V2 during replay. 4. **Multi-hop upcasting** – V1 → V2 → V3 chains work end-to-end. 5. **Unknown events** – Missing event types produce `UnknownEvent` and replay continues. 6. **Metadata preservation** – The store re-attaches `EventMetadata` (idempotency, correlation, user) after upcasting. 7. **Cycle detection** – Circular upcaster chains are rejected at registry construction. ## Related Files - **Core abstractions:** `src/src/EventSourcing/Aggregates/Events/EventContractAttribute.cs`, `EventMetadata.cs`, `src/src/EventSourcing/Aggregates/Events/Upcasting/IEventUpcaster.cs`, `EventUpcasterRegistry.cs` - **SQL Server replay:** `src/src/SqlServer/Events/SqlServerEventStore.GetEventRangeAsync.cs` (reference implementation) - **Sample:** [Event-Versioning-Examples.md](../event-versioning-examples/) - **Tests:** Provider-specific replay tests (to be harmonized) --- # Event Versioning: Practical Examples This guide provides practical examples of implementing event versioning in Purview EventSourcing. ## Table of Contents 1. [Additive Changes (No Versioning Needed)](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#additive-changes) 2. [Versioning with SchemaVersion](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#versioning-with-schemaversion) 3. [Single-Hop Upcasting](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#single-hop-upcasting) 4. [Multi-Hop Upcasting Chains](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#multi-hop-upcasting-chains) 5. [Common Mistakes & How to Avoid Them](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#common-mistakes) 6. [Testing Versioned Events](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#testing-versioned-events) ## Additive Changes When you add a new optional field to an event, no versioning is needed. Old events will deserialize successfully with the new field set to its default value. ### Example: Adding an Optional Phone Number **Initial event (v1, implicit `SchemaVersion = 1`):** ```csharp [EventContract] public sealed record CustomerRegisteredEvent { public string CustomerId { get; set; } = default!; public string Email { get; set; } = default!; } ``` **After adding an optional field (still v1, no SchemaVersion bump needed):** ```csharp [EventContract] public sealed record CustomerRegisteredEvent { public string CustomerId { get; set; } = default!; public string Email { get; set; } = default!; public string? PhoneNumber { get; set; } // Optional, new field } ``` **Aggregate apply logic:** ```csharp protected override void RegisterEvents() { Register(cr => { CustomerId = cr.CustomerId; Email = cr.Email; PhoneNumber = cr.PhoneNumber ?? "N/A"; }); } ``` Old events will deserialize with `PhoneNumber = null`, and the aggregate handles it gracefully. --- ## Versioning with SchemaVersion When you make a **breaking change** to an event's payload (required field added, meaning changed, property removed), bump the `SchemaVersion`. ### Example: Making Phone Number Required **Old event (v1):** ```csharp [EventContract] public sealed record CustomerRegisteredEvent { public string CustomerId { get; set; } = default!; public string Email { get; set; } = default!; public string? PhoneNumber { get; set; } // Was optional } ``` **New event (v2, breaking change):** ```csharp [EventContract] public sealed record CustomerRegisteredEvent { public string CustomerId { get; set; } = default!; public string Email { get; set; } = default!; public string PhoneNumber { get; set; } = default!; // Now required public static int SchemaVersion => 2; } ``` ### Defining the Upcaster A same-type upcaster transforms the payload in place. The store re-attaches `EventMetadata` after upcasting, so the upcaster only maps payload fields. ```csharp public sealed class CustomerRegisteredV1ToV2Upcaster : IEventUpcaster { public CustomerRegisteredEvent Upcast(CustomerRegisteredEvent source) => new() { CustomerId = source.CustomerId, Email = source.Email, PhoneNumber = source.PhoneNumber ?? "UNKNOWN", // Default for old events }; } ``` --- ## Single-Hop Upcasting Single-hop upcasting converts v1 events directly to v2 during replay. ### Full Example: Order Events **Step 1: Define the events** ```csharp // Order event v1 (no currency) [EventContract] public sealed record OrderCreatedEventV1 { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } } // Order event v2 (with currency, breaking change) [EventContract] public sealed record OrderCreatedEvent { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } public string Currency { get; set; } = default!; public static int SchemaVersion => 2; } ``` **Step 2: Define the upcaster** ```csharp public sealed class OrderCreatedV1ToV2Upcaster : IEventUpcaster { public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) => new() { OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", // Default currency for old events }; } ``` **Step 3: Register the upcaster in DI** ```csharp services.AddEventUpcaster(); ``` **Step 4: Use in the aggregate** ```csharp public sealed class OrderAggregate : AggregateBase { public string OrderId { get; private set; } = default!; public decimal Amount { get; private set; } public string Currency { get; private set; } = default!; protected override void RegisterEvents() { // Old event type (will be upcast to OrderCreatedEvent) Register(v1 => { OrderId = v1.OrderId; Amount = v1.Amount; Currency = "USD"; }); // New event type (v2) Register(oc => { OrderId = oc.OrderId; Amount = oc.Amount; Currency = oc.Currency; }); } } ``` --- ## Multi-Hop Upcasting Chains Multi-hop chains (v1 → v2 → v3) are automatically applied during replay. ### Full Example: Three Event Versions **Step 1: Define the events** ```csharp // v1: OrderCreatedEventV1 [EventContract] public sealed record OrderCreatedEventV1 { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } } // v2: OrderCreatedEventV2 (added currency) [EventContract] public sealed record OrderCreatedEventV2 { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } public string Currency { get; set; } = default!; public static int SchemaVersion => 2; } // v3: OrderCreatedEvent (added tax info) [EventContract] public sealed record OrderCreatedEvent { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } public string Currency { get; set; } = default!; public decimal TaxAmount { get; set; } public static int SchemaVersion => 3; } ``` **Step 2: Define the upcasters** ```csharp public sealed class OrderCreatedV1ToV2Upcaster : IEventUpcaster { public OrderCreatedEventV2 Upcast(OrderCreatedEventV1 source) => new() { OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", }; } public sealed class OrderCreatedV2ToV3Upcaster : IEventUpcaster { public OrderCreatedEvent Upcast(OrderCreatedEventV2 source) => new() { OrderId = source.OrderId, Amount = source.Amount, Currency = source.Currency, TaxAmount = source.Amount * 0.1m, // 10% tax on amount }; } ``` **Step 3: Register both upcasters** ```csharp // Order matters: register from earliest to latest version services.AddEventUpcaster(); services.AddEventUpcaster(); ``` **Step 4: Aggregate receives the final upcast event** ```csharp public sealed class OrderAggregate : AggregateBase { public string OrderId { get; private set; } = default!; public decimal Amount { get; private set; } public string Currency { get; private set; } = default!; public decimal TaxAmount { get; private set; } protected override void RegisterEvents() { // The upcaster chain is applied before the aggregate applies the event. // Old v1 and v2 events arrive as OrderCreatedEvent (v3) after upcasting. Register(oc => { OrderId = oc.OrderId; Amount = oc.Amount; Currency = oc.Currency; TaxAmount = oc.TaxAmount; }); } } ``` During replay: - V1 events → upcast by `OrderCreatedV1ToV2Upcaster` → upcast by `OrderCreatedV2ToV3Upcaster` → arrive as `OrderCreatedEvent` - V2 events → upcast by `OrderCreatedV2ToV3Upcaster` → arrive as `OrderCreatedEvent` - V3 events → arrive as-is (no upcasting needed) --- ## Common Mistakes ### ❌ Mistake 1: Copying Metadata in the Upcaster Metadata (`EventMetadata`: idempotency, correlation, user, timestamp, schema version) is carried by the framework and re-attached by the store, so it should **not** be copied by the upcaster. **Wrong:** ```csharp public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) { return new() { Metadata = source.Metadata, // Metadata is framework-managed; do not copy it OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", }; } ``` **Correct:** ```csharp public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) { return new() { OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", }; } ``` The store attaches the source event's `EventMetadata` (idempotency, correlation, user) to the upcast event automatically. ### ❌ Mistake 2: Creating a New Event Type Instead of Versioning If the semantic meaning changes (e.g., "registration" → "registration with email verification"), create a **new event type**, not a new version. **Wrong (semantic change, not a versioning scenario):** ```csharp [EventContract] public sealed record UserRegisteredEvent { public string Email { get; set; } = default!; public static int SchemaVersion => 2; public bool EmailVerified { get; set; } // Required, breaking change } ``` This conflates two different processes. **Correct (introduce a new event type):** ```csharp [EventContract] public sealed record UserRegisteredEvent { public string Email { get; set; } = default!; } [EventContract] public sealed record UserRegisteredWithEmailVerificationEvent { public string Email { get; set; } = default!; public bool EmailVerified { get; set; } } ``` ```csharp protected override void RegisterEvents() { Register(ur => { Email = ur.Email; EmailVerified = false; }); Register(urwv => { Email = urwv.Email; EmailVerified = urwv.EmailVerified; }); } ``` ### ❌ Mistake 3: Forgetting a Safe Default for Legacy Values When a breaking change adds a field, the upcaster must provide a deterministic default for old events. Otherwise the aggregate applies a meaningless value. **Wrong:** ```csharp public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) { return new() { OrderId = source.OrderId, Amount = source.Amount, // Currency omitted — old events would hydrate Currency = null }; } ``` **Correct:** ```csharp public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) { return new() { OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", // Deterministic default for legacy events }; } ``` ### ❌ Mistake 4: Circular Upcaster Chains The registry detects circular chains and throws an exception when it is constructed, but you can prevent this by registering upcasters in order (v1 → v2 → v3). **Wrong:** ```csharp // This will throw at runtime services.AddEventUpcaster(); services.AddEventUpcaster(); // Creates a cycle! ``` **Correct:** ```csharp // Always register from earlier to later versions services.AddEventUpcaster(); services.AddEventUpcaster(); ``` --- ## Testing Versioned Events ### Unit Test: Single-Hop Upcasting ```csharp [Test] public async Task Upcast_V1ToV2_PreservesDataAndDefaults() { var upcaster = new OrderCreatedV1ToV2Upcaster(); var v1Event = new OrderCreatedEventV1 { OrderId = "123", Amount = 99.99m, }; var v2Event = upcaster.Upcast(v1Event); await Assert.That(v2Event.OrderId).IsEqualTo("123"); await Assert.That(v2Event.Amount).IsEqualTo(99.99m); await Assert.That(v2Event.Currency).IsEqualTo("USD"); } ``` ### Integration Test: Replay with Upcasting Legacy v1 rows are produced by an older deployment; the test seeds one directly in storage, then loads the aggregate so the upcaster runs during replay. ```csharp [Test] public async Task Replay_WithV1Events_UpcastsToV2AndAppliesCorrectly() { // 1. Register upcaster services.AddEventUpcaster(); // 2. Seed a V1 event row directly in storage (provider-specific), for example // write an OrderCreatedEventV1 payload under the aggregate's stream. // 3. Load the aggregate (triggers replay with upcasting) var aggregate = await eventStore.GetAsync("123", cancellationToken); // 4. Verify the aggregate state matches the upcast event await Assert.That(aggregate.OrderId).IsEqualTo("123"); await Assert.That(aggregate.Amount).IsEqualTo(99.99m); await Assert.That(aggregate.Currency).IsEqualTo("USD"); // Upcast default } ``` ### Testing Unknown Events ```csharp [Test] public async Task Replay_WithUnknownEventType_ReturnsUnknownEventAndContinues() { // 1. Seed an event whose persisted type name does not resolve to a registered event type // 2. Load the aggregate var aggregate = await eventStore.GetAsync("123", cancellationToken); // 3. Verify replay continues without throwing await Assert.That(aggregate).IsNotNull(); await Assert.That(aggregate.SkippedEvents).IsNotEmpty(); // 4. In a real test, you'd have a mixture of known and unknown events // to verify partial replay works correctly } ``` --- ## Summary - **Additive changes (optional fields)** → No versioning needed - **Breaking changes (required fields, removed fields, semantic changes)** → Increment SchemaVersion - **Semantic meaning changes** → Create a new event type - **Do not copy metadata in upcasters** — the store re-attaches `EventMetadata` - **Register upcasters in order** (v1 → v2 → v3 → …) - **Test multi-hop chains** and unknown event handling - **Stream-backed providers apply upcasting during replay** (SQL Server, PostgreSQL, Azure Storage, MongoDB) For more information, see [Event-Versioning-Strategy.md](../event-versioning-strategy/). --- # Snapshot Schema Versioning Snapshots are replaceable optimizations. Event streams remain the source of truth. When an aggregate change makes older serialized snapshots unsafe to read, declare a new snapshot schema version: ```csharp [SnapshotSchemaVersion(2)] [Aggregate] public sealed partial class Order : AggregateBase { } ``` The default version is 1, so existing aggregates and storage names remain compatible. Versions must be positive and are inherited by derived aggregate types. SQL Server and PostgreSQL store the version on the snapshot row. MongoDB stores it on the snapshot document. Azure Storage uses a versioned blob name. Distributed-cache keys are also versioned. A mismatch is detected before payload deserialization; the store ignores the snapshot and rebuilds the aggregate from its complete event stream. The next snapshot-eligible save writes the current schema and replaces or supersedes the incompatible snapshot. This fallback is safe because it never mutates the event stream and never treats a snapshot as canonical state. A version bump may temporarily increase replay work, so deploy it before removing runtime types or converters needed by old snapshot payloads if a rolling deployment must support both application versions. ## Administrative inspection and rebuild The Admin portal reports whether a snapshot is materialized for an aggregate (`GET /admin/api/aggregates/{aggregateType}/{aggregateId}/snapshot`, opt-in via `ViewSnapshot`) and can reconstruct a snapshot from the canonical event stream (`POST /admin/api/aggregates/{aggregateType}/{aggregateId}/snapshot/rebuild`, opt-in, separately authorized, and audited via `RebuildSnapshot`). Rebuild is idempotent and requires both an event-backed `IEventStore` and a registered `IQueryableEventStore`; it never mutates the event stream. --- # Shared Testing Framework This page describes the shared provider-agnostic test framework used by the storage-provider integration suites. ## Overview Each storage provider (`AzureStorage`, `CosmosDb`, `MongoDB`, `Postgres`, `SqlServer`) has its own integration test project. Instead of duplicating the same behavioural tests for every provider, the repository defines two shared contract suites that run against every provider that advertises the relevant capability: - **Event-store contract suite** — runs against providers with an event store: Azure Storage, MongoDB, Postgres, SQL Server. (Cosmos DB has no event stream.) - **Snapshot-store contract suite** — runs against providers with a query snapshot store: Cosmos DB, MongoDB, Postgres, SQL Server. (Azure Storage has no query snapshot store.) Provider-specific behaviour (batch limits, index creation, JSON operators, query-translation boundaries, telemetry, storage layout) lives in per-provider guard tests in each integration project. ## Layout | Path | Purpose | | --- | --- | | `src/tests/SharedTestingFramework/Contracts/` | The shared contract suites. These are **not** compiled into the `SharedTestingFramework` assembly; each provider integration test project links them into its own compilation (see below). | | `src/tests/SharedTestingFramework/Fixtures/` | Provider Testcontainers fixtures. | | `src/tests/.IntegrationTests/Events/` | The per-provider event-store wiring + guard tests. | | `src/tests/.IntegrationTests/Snapshots/` | The per-provider snapshot-store wiring + guard tests. | | `src/tests/.IntegrationTests/Guards/` | Provider-specific event-store guard tests. | ## How the shared suites are wired TUnit uses compile-time discovery, so the `[Test]` methods must be discoverable from the test assembly. The shared suites achieve this with three TUnit features: 1. `[GenerateGenericTest(typeof(PersistenceAggregate))]` on a generic test class makes TUnit generate a concrete test class for the supplied aggregate type. 2. `[ClassDataSource(Shared = SharedType.PerTestSession)]` injects the provider fixture (one container per test session). 3. `[InheritsTests]` picks up the `[Test]` methods declared on the shared generic base class (`EventStoreContractTestsBase` / `SnapshotStoreContractTestsBase`). The contract sources under `Contracts/` are linked directly into each provider test project's compilation (via `` links) rather than consumed cross-assembly. This is deliberate: TUnit's `TestMetadataGenerator` reports error diagnostic `TUNIT0999` at the inherited method's source location when an internal generation error occurs, and Roslyn's `SourceProductionContext.ReportDiagnostic` rejects diagnostics whose location is not part of the compilation being analyzed (surfacing as `CS8785`). With a cross-assembly base class the location points into `SharedTestingFramework`'s sources, outside the provider compilation, so the warning is unavoidable. Linking the sources keeps every inherited `[Test]` method inside the provider compilation and eliminates the failure. `SharedTestingFramework` therefore no longer carries the base `[Test]` methods at all. Each provider test project therefore adds a small derived class such as: ```csharp [GenerateGenericTest(typeof(PersistenceAggregate))] [ClassDataSource(Shared = SharedType.PerTestSession)] [InheritsTests] public sealed class EventStoreContractTests(SqlServerEventStoreFixture fixture) : EventStoreContractTestsBase where TAggregate : class, IAggregateTest, new() { protected override IEventStoreCore CreateEventStore() => fixture.CreateEventStore(); protected override IEventStoreCore CreateEventStore(IAggregateChangeFeedNotifier? notifier) => fixture.CreateEventStore(aggregateChangeNotifier: notifier); protected override Task MarkEventTypesAsUnknownAsync(...) => /* provider-specific event rewrite */; } ``` The shared base classes only exercise the public contracts (`IEventStoreCore` and `IQueryableEventStoreCore`), observable state (save results, rehydrated aggregates, change-feed notifications, event ranges, query results) and shared aggregates (`PersistenceAggregate`, `ComplexTestType`). Provider internals are deliberately out of scope for the shared suites. ## Where each suite runs | Suite | AzureStorage | CosmosDb | MongoDB | Postgres | SqlServer | | --- | --- | --- | --- | --- | --- | | Event-store contract suite | ✓ | — | ✓ | ✓ | ✓ | | Snapshot-store contract suite | — | ✓ | ✓ | ✓ | ✓ | | Provider guard / feature tests | ✓ | ✓ | ✓ | ✓ | ✓ | ## Adding a new provider 1. Create the integration test project (see `project-placement-defaults`), referencing `SharedTestingFramework` and `Samples`, and link the contract sources that the provider needs (the `EventStore*` and/or `SnapshotStore*` files under `Contracts/`) — see the `` links in the existing provider test projects. 2. Add an `EventStoreContractTests` (and/or `SnapshotStoreContractTests`) derived class wired to the provider fixture. 3. Implement the provider-specific seams (`MarkEventTypesAsUnknownAsync` for the unknown-event test, `SnapshotAsync` for the snapshot suite). 4. Add guard tests for capabilities that are not part of the shared contract. ## Adding a shared test 1. Add the `[Test]` method to the relevant shared base class under `Contracts/`. 2. Add any data sources to the matching `*ContractTestData` static class. 3. The test runs automatically for every provider whose integration project links the contract sources and derives from the relevant base class. ## Environment notes - Integration suites require Docker and the provider images (Testcontainers). - The Azure/Cosmos/Mongo snapshot fixtures rely on Azurite; the SQL fixtures on a SQL Server image. - The CI pipeline runs only the unit-test tree filter; integration suites are exercised locally or via an opt-in run. --- # Source Generator Behaviors This page documents framework-level source-generator behavior (not storage-provider behavior). ## Aggregate eligibility and inheritance `[Aggregate]` supports three inheritance paths: 1. No declared base class: generated partial type automatically inherits `AggregateBase`. 2. Direct inheritance from `AggregateBase`. 3. 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 ```csharp // 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 inheritance public 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 Default event namespace: - `.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 with `EventSuffix` defaults/overrides). - Typical generated type: `Testing.OrderEvents.OrderCreatedEvent`. Namespace can be overridden per method (`EventNamespace`) or by aggregate defaults. ### Event naming examples ```csharp 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) :::note An explicit `EventName` is used verbatim: the generator does **not** append the `Event` suffix when a name is provided. Include the suffix in the explicit name (for example `EventName = "OrderRegisteredEvent"`) if you want the generated type to end in `Event`. The `Event` suffix is only appended to inferred names. ::: ## Hook behavior semantics Property hooks are property-scoped: - `OnChanging(ref value)` runs on generated command methods before event creation. - `OnChanged(previous, current)` runs in generated `Apply(...)` 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(...)`. - `OnChanged` runs on replay. - `OnChanging` does not run on replay. Event hooks are event-scoped: - `OnRaisingEvent(ref ...)` - `OnRaisedEvent(@event)` - `OnAppliedEvent(@event)` - `OnShouldApplyEvent(@event, ref bool shouldApply)` Manual behavior: - `Manual = true` does not auto-wire property hooks unless manual code invokes them. ### Property hook example ```csharp [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 - `[Event]` methods must be `partial` declarations 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]`) require `EventStoreList` / `EventStoreSet` target properties. ### Event contracts Events are emitted as `[EventContract]` `sealed record` types — pure payload data with no base class or interface: ```csharp [EventContract] public sealed record OrderCreatedEvent { public static int SchemaVersion => 1; [JsonIgnore] public EventMetadata Metadata { get; set; } 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 via `Register`/`RegisterGenerated` 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. - `GetHashCode` is content-based for payload properties and metadata, preserving stable event hashing (used by the Azure idempotency compound key). - The generated `RegisterEvents` registers appliers with `RegisterGenerated()`, which resolves the generated `Apply(TEvent)` method once per aggregate/event type and shares it statically, so aggregate construction allocates no per-instance applier delegates. ### Generated command method shape A generated command method constructs **one** event instance, runs the property `OnChanging` 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 ```csharp [Aggregate] public partial class ReportAggregate : AggregateBase { public EventStoreSet Tags { get; private set; } = []; [CollectionEvent(nameof(Tags))] public partial void AddTag(string tag); } ``` ### 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 an `ArgumentNullException` guard and emits the event property as non-nullable. - `[Required]` (`System.ComponentModel.DataAnnotations`) on a nullable `string` parameter generates an `ArgumentException` guard 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. ```csharp [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?`. The generated record carries the `[EventContract]` attribute and the usual `Metadata`/`SchemaVersion` members (see the event-contract shape above); it has **no base class**: ```csharp [EventContract] public sealed record BioUpdatedEvent { public string Bio { get; set; } = default!; } ``` ## Value-object conversion behavior > The `[Scalar]` / `[ValueObject]` generator and analyzer are provided by the `Purview.ValueObjects` package, > referenced transitively by `Purview.EventSourcing`. Value objects live in the `Purview.ValueObjects` and > `Purview.ValueObjects.Serialization` namespaces (previously `Purview.EventSourcing.ValueObjects` and > `Purview.EventSourcing.Serialization`). - Generated mapping paths use `Create(...)` semantics for strict command-time conversion/validation. - Contextual `Create(TValue, in ValueObjectContext)` 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. The providers detect the marker by full type name (`Purview.EventSourcing.EntityFrameworkCore.EFOpaqueAttribute`) and persist marked members as converted JSON scalars. - `EVENTSTOREEF001` reports 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. - `EVENTSTOREEF002` reports 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. - `EVENTSTOREEF003` reports a snapshot complex member whose type exposes no constructor EF can bind. EF cannot bind complex or collection constructor parameters while materializing JSON, so value types (and reference types without a parameterless constructor) need a parameterless constructor or scalar-only constructor parameters. The `Purview.ValueObjects` generator emits a parameterless constructor for `[ValueObject]` types. - `EVENTSTOREEF004` reports a complex member without an `init`/`set` accessor declared on a value type. EF writes value-type JSON members through their backing field, and a read-only member cannot be assigned during materialization. The SQL Server and PostgreSQL providers also reject the shape while building the snapshot query model, so consumers that do not run the analyzer still receive an actionable error instead of EF's internal `ArgumentException: Expression must be writeable`. - 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 ```csharp // 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 */ } } ``` ```csharp // Contextual conversion [Scalar] public readonly partial record struct OrderStatus : IContextualValueObject { public OrderStatusCode Value { get; } public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext context) => IsValidTransition(context.Aggregate.Status.Value, value) ? new(value) : throw new InvalidOperationException(); } ``` ## Diagnostics to expect Model-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 do not report these validation diagnostics. Analyzer diagnostics can be suppressed or configured through the usual `#pragma warning` / `.editorconfig` mechanisms. The exception is the event-contract manifest baseline comparison: the generator itself reports `EVENTSTORE030`–`EVENTSTORE036` when the current contracts differ from the committed baseline (see [Event Contract Manifest](../event-contract-manifest/)). Those diagnostics are emitted from the generator's output stage, so they always surface on a build regardless of analyzer configuration. Common aggregate diagnostic IDs: - `EVENTSTORE001` aggregate must be partial - `EVENTSTORE002` aggregate must inherit `AggregateBase` (or have no base so generator can add it) - `EVENTSTORE003` nested aggregates unsupported - `EVENTSTORE004` generic aggregates unsupported - `EVENTSTORE005` manual `RegisterEvents` unsupported - `EVENTSTORE007` generated event method must be partial - `EVENTSTORE009` duplicate generated event names - `EVENTSTORE010` parameter must map to writable property - `EVENTSTORE018` unsupported aggregate collection property type - `EVENTSTORE021` event schema version must be positive - `EVENTSTORE022` duplicate event schema version on aggregate Value-object diagnostics are provided by the `Purview.ValueObjects` package (the `[Scalar]`/`[ValueObject]` generator and analyzer moved out of this repository). They use the `VO1001`–`VO1008` ID range; see the `Purview.ValueObjects` package documentation for the full list. 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 validation diagnostics are always surfaced by the analyzer assets that ship in the same package. The manifest-compatibility diagnostics (`EVENTSTORE030`–`036`), by contrast, are reported by the generator itself. ## 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 a `CodeQuery` over the generated trees (backed by the output compilation). - Prefer `GetClass`/`GetRecord`/`GetStruct`/`GetEnum`/`HasNamespace`, `HasMethod`, `HasProperty`, `HasConstructor`, and `TypeReference`-based parameter matching for member signatures. - Keep string assertions only for method-body statements that `CodeQuery` does not model (for example `RecordAndApply(@event);`), scoped to the returned syntax node's body. - Operator declarations are `OperatorDeclarationSyntax`, not methods; assert them via `CodeQuery.GetOperator`/`HasOperator`/`TryGetOperator` (optionally scoped with `CodeQuery.In(type)`), or `GetConversionOperator` for `implicit`/`explicit` conversions. 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`. --- # Source Generator Code Fixes The source-generator package ships IDE code fixes alongside its diagnostics. Fixes live in a **separate analyzer assembly** (`Purview.EventSourcing.SourceGenerator.CodeFixes`) so the core `Purview.EventSourcing.SourceGenerator` assembly never acquires a `Microsoft.CodeAnalysis.Workspaces` dependency. ## Assembly separation | Assembly | Contents | Workspaces dependency | | --- | --- | --- | | `Purview.EventSourcing.SourceGenerator` | Incremental generators, the analyzer, and all diagnostic descriptors | None | | `Purview.EventSourcing.SourceGenerator.CodeFixes` | `CodeFixProvider` implementations that use `DocumentEditor` / `SyntaxGenerator` | Yes (PrivateAssets) | Both assemblies are packed under `analyzers/dotnet/cs` of `Purview.EventSourcing`. No Workspaces assembly is shipped in the package and no Workspaces dependency flows into consumer projects. The compiler loads the code-fix assembly without instantiating its Workspaces-dependent types; the IDE activates them for code fixes. ## Available fixes | Diagnostic | Fix | Notes | | --- | --- | --- | | `EVENTSTORE001` (aggregate must be partial) | Adds `partial` to the aggregate declaration | Trivia, nesting, generic parameters, and accessibility preserved | | `EVENTSTORE101` (value object must be partial) | Adds `partial` to the value-object declaration | Works for record structs, structs, and classes | | `EVENTSTORE007` (event method must be partial) | Adds `partial` to the method | | | `EVENTSTORE021` (schema version must be positive) | Resets the version to `1` | Only when the version argument is explicit | | `EVENTSTORE022` (duplicate schema version) | Moves the version to the next unused version on the aggregate | Only when the version argument is explicit | Fixes use stable equivalence keys and support **Fix All** where safe. No fix is offered when a correct correction is ambiguous (for example a renamed event contract or an incompatible payload change); the diagnostic message provides guidance instead. ## Reference The fixes share the diagnostic descriptors defined in the source-generator assembly via `InternalsVisibleTo`; no diagnostic is defined in the code-fix assembly. --- # SQL Server Event and Snapshot Stores Purview Event Sourcing ships separate SQL Server-backed event and snapshot implementations in a single NuGet package: - `Purview.EventSourcing.SqlServer` + `SqlServerEventStore`: pure event-sourced store where events remain the source of truth. - `Purview.EventSourcing.SqlServer` + `SqlServerSnapshotEventStore`: queryable snapshot store optimized for query/list/count over snapshots. These two concepts are related but **not interchangeable**: - the SQL Server **event store** keeps internal stream snapshots in its event table to speed aggregate rehydration and event-based operations, - the **queryable snapshot store** is an optional LINQ/query-optimized store that can be omitted entirely or implemented by a different provider. Both stores create their tables automatically on first use (configurable) and use a **single shared table** for all aggregate types. --- ## Table of Contents 1. [Installation](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#installation) 2. [Quick Start](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#quick-start) 3. [Configuration Reference](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#configuration-reference) 4. [Required SQL Server Permissions](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#required-sql-server-permissions) 5. [Single-Table Design](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#single-table-design) 6. [Per-Aggregate Schema and Table Routing](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#per-aggregate-schema-and-table-routing) 7. [Event Schema Versioning](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#event-schema-versioning) 8. [SQL Transaction Coordination](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#sql-transaction-coordination) 9. [JSON Index Configuration](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#json-index-configuration) 10. [Snapshot Payload Shape](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#snapshot-payload-shape) 11. [Behavior Notes and Caveats](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#behavior-notes-and-caveats) 12. [Connection String Examples](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#connection-string-examples) --- ## Installation ```xml ``` --- ## Quick Start ### Events Store ```csharp // Program.cs builder.Services.AddSqlServerEventStore(); // appsettings.json { "EventStore:SqlServer": { "ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;", "SchemaName": "dbo", "TableName": "EventStore" } } ``` Inject `IEventStore` for the provider-agnostic facade, or `ISqlServerEventStore` when you need the typed SQL Server implementation directly: ```csharp public class OrderService(IEventStore store) { public async Task PlaceOrderAsync(string orderId, string customerId) { var order = await store.GetOrCreateAsync(orderId); order.CreateOrder(customerId, 0m); await store.SaveAsync(order); } } ``` ### Event-history API (version and time filters) The provider-agnostic facade exposes aggregate history reads for audit/review use-cases: ```csharp var response = await store.GetEventHistoryAsync( orderId, new AggregateEventHistoryRequest { FromVersion = 1, ToVersion = 200, FromUtc = DateTimeOffset.UtcNow.AddDays(-30), ToUtc = DateTimeOffset.UtcNow, MaxRecords = 100 }, cancellationToken); ``` The response is a `ContinuationResponse` so callers can page using the returned `ContinuationToken`. ### Snapshot Store ```csharp // Program.cs builder.Services.AddSqlServerEventStore(); builder.Services.AddSqlServerSnapshotQueryableEventStore(); // appsettings.json { "EventStore:SqlServerSnapshot": { "ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;", "SchemaName": "dbo", "TableName": "EventStoreSnapshots" } } ``` --- ## Configuration Reference ### Events Store (`SqlServerEventStoreOptions`) | Property | Type | Default | Description | | --- | --- | --- | --- | | `ConnectionString` | `string` | *(required)* | ADO.NET connection string | | `SchemaName` | `string` | `"dbo"` | Default schema for the events table | | `TableName` | `string` | `"EventStoreEvents"` | Default table name for events | | `AutoCreateTable` | `bool` | `true` | Create table and indices on first use | | `UseDataCompression` | `bool` | `true` | Apply `PAGE` compression (Enterprise / Azure SQL) | | `TimeoutInSeconds` | `int?` | `60` | Command timeout (1–120 000 s) | | `MaxEventCountOnSave` | `int` | `1000` | Maximum events per save operation | | `EventSuffixLength` | `int` | `30` | Zero-padded version suffix on event row IDs | | `RemoveDeletedFromCache` | `bool` | `true` | Evict deleted aggregates from distributed cache | | `CacheMode` | `SnapshotCachingOptions` | `GetAndStore` | Distributed-cache interaction policy | | `DefaultCacheSlidingDuration` | `TimeSpan` | `60 min` | Sliding cache expiry | | `RequiresValidPrincipalIdentifier` | `bool` | `true` | Require a `ClaimsPrincipal` identifier on save | | `AggregateTableOverrides` | `Dictionary` | `{}` | Per-aggregate schema/table overrides | | `JsonIndexOptions` | `SqlServerJsonIndexOptions` | disabled / empty | Runtime-managed JSON computed columns and indexes for `Payload` | ### Snapshot Store (`SqlServerSnapshotEventStoreOptions`) | Property | Type | Default | Description | | --- | --- | --- | --- | | `ConnectionString` | `string` | *(required)* | ADO.NET connection string | | `SchemaName` | `string` | `"dbo"` | Default schema for the snapshots table | | `TableName` | `string` | `"EventStoreSnapshots"` | Default table name | | `AutoCreateTable` | `bool` | `true` | Create table on first use | | `UseDataCompression` | `bool` | `true` | Apply `PAGE` compression | | `AggregateTableOverrides` | `Dictionary` | `{}` | Per-aggregate schema/table overrides | | `JsonIndexOptions` | `SqlServerJsonIndexOptions` | disabled / empty | Runtime-managed JSON computed columns and indexes for `Payload` | --- ## Required SQL Server Permissions ### Minimum Runtime Permissions Grant the application's login (or contained-database user) the following on every schema/table it uses: ```sql -- On the schema GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::[dbo] TO [app_login]; -- Or more targeted, per table: GRANT SELECT, INSERT, UPDATE, DELETE ON [dbo].[EventStore] TO [app_login]; GRANT SELECT, INSERT, UPDATE, DELETE ON [dbo].[Snapshots] TO [app_login]; ``` ### Auto-Create Permissions (`AutoCreateTable = true`) When `AutoCreateTable` is enabled (the default), the application also needs DDL rights at startup to create the table, computed columns, and indices: ```sql -- Required to create tables and indices in the schema: GRANT CREATE TABLE TO [app_login]; GRANT ALTER ON SCHEMA::[dbo] TO [app_login]; ``` :::tip Use a separate migration user or initialisation step in CI/CD with elevated permissions, then set `AutoCreateTable = false` in production to avoid granting DDL rights to the runtime user. ::: ### Minimal Role-Based Setup (SQL Server) ```sql -- Create a dedicated role for the event store CREATE ROLE [event_store_rw]; GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::[dbo] TO [event_store_rw]; ALTER ROLE [event_store_rw] ADD MEMBER [app_login]; -- Additionally for auto-create: CREATE ROLE [event_store_ddl]; GRANT CREATE TABLE TO [event_store_ddl]; GRANT ALTER ON SCHEMA::[dbo] TO [event_store_ddl]; ALTER ROLE [event_store_ddl] ADD MEMBER [migration_login]; ``` ### Azure SQL (Managed Identity) ```sql -- Create contained user for Managed Identity CREATE USER [my-app-service] FROM EXTERNAL PROVIDER; ALTER ROLE db_datawriter ADD MEMBER [my-app-service]; ALTER ROLE db_datareader ADD MEMBER [my-app-service]; -- For auto-create only: GRANT CREATE TABLE TO [my-app-service]; GRANT ALTER ON SCHEMA::[dbo] TO [my-app-service]; ``` --- ## Single-Table Design Both stores use a **single shared table** by default. All aggregate types are stored in the same table and distinguished by the `AggregateType` column. For clarity: - `SqlServerEventStore` stores stream metadata, events, idempotency markers, and **internal replay snapshots** in its event table. - `SqlServerSnapshotEventStore` stores **queryable snapshots** in a separate snapshot table for LINQ-based reads. The internal replay snapshots in the event table are part of the event-store implementation and should not be treated as redundant copies of the optional queryable snapshot store. ### Events table schema ```text [Id] NVARCHAR(450) PK [EntityType] INT 0=StreamVersion, 1=Event, 2=IdempotencyMarker, 3=Snapshot [AggregateId] NVARCHAR(450) The aggregate's id [AggregateType] NVARCHAR(450) Kebab-case aggregate type (e.g. "order") [Version] INT Aggregate version at time of event [IsDeleted] BIT Soft-delete flag on the stream-version row [Payload] JSON / NVARCHAR(MAX) JSON payload (events and snapshots) [EventType] NVARCHAR(450) Mapped event type name (e.g. "order.order-created") [IdempotencyId] NVARCHAR(450) Idempotency marker id [Timestamp] DATETIMEOFFSET UTC timestamp of the operation ``` Three covering indices are created automatically (named with the configured table name, so `IX_EventStoreEvents_*` with the default table): | Index | Columns | Purpose | | --- | --- | --- | | `IX_{table}_AggregateId_EntityType` | `(AggregateId, AggregateType, EntityType)` INCLUDE `(Version, IsDeleted)` | Stream lookups, idempotency markers, and deletes | | `IX_{table}_EventRange` | `(AggregateId, AggregateType, Version)` WHERE EntityType=1 INCLUDE `(Payload, EventType, IdempotencyId, SchemaVersion, CorrelationId, CausationId, UserId, Timestamp)` | Event replay | | `IX_{table}_AggregateType_EntityType` | `(AggregateType, EntityType, IsDeleted)` INCLUDE `AggregateId` | Aggregate ID enumeration | :::note The single-table design minimises DDL surface area and allows aggregates from different bounded contexts to share a connection pool and database. **Aggregate ID vs type scoping:** when multiple aggregate types share the same schema/table, event-stream read/delete queries scope by both `AggregateId` and `AggregateType`. If you isolate aggregate types by schema/table via `AggregateTableOverrides`, that physical separation provides the same isolation boundary. ::: --- ## Per-Aggregate Schema and Table Routing Use `AggregateTableOverrides` to route specific aggregate types to a dedicated schema or table. This is useful when you want bounded-context isolation at the database level while still sharing a connection string. The dictionary key is the aggregate's **`AggregateType`** value — the kebab-case type name derived from the class (e.g. `"order"` for `OrderAggregate`). Keys are **case-insensitive**, so `"Order"` also matches. ### Code-based configuration ```csharp builder.Services.AddSqlServerEventStore(); builder.Services.Configure(options => { options.ConnectionString = "Server=.;Database=MyApp;Trusted_Connection=True;"; // Orders aggregate uses the "orders" schema options.AggregateTableOverrides["order"] = new SqlServerAggregateTableOverride { SchemaName = "orders", TableName = "EventStore", // optional — falls back to global TableName }; // Inventory uses a completely separate table options.AggregateTableOverrides["inventory"] = new SqlServerAggregateTableOverride { SchemaName = "inventory", TableName = "DomainEvents", }; }); ``` ### appsettings.json configuration ```json { "EventStore:SqlServer": { "ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;", "SchemaName": "dbo", "TableName": "EventStore", "AggregateTableOverrides": { "order": { "SchemaName": "orders" }, "inventory": { "SchemaName": "inventory", "TableName": "DomainEvents" } } } } ``` ### How it works When `SqlServerEventStore` is constructed it looks up `T`'s `AggregateType` name in `AggregateTableOverrides`. If a match is found: - `SchemaName` override (if set) replaces the global `SchemaName` - `TableName` override (if set) replaces the global `TableName` - All other options (compression, timeouts, caching…) are inherited from the global options Each overridden aggregate type gets its own table with its own set of automatically-created indices. :::note **Permissions note:** If you use per-aggregate schema routing you must grant the runtime user `SELECT/INSERT/UPDATE/DELETE` on **each** schema/table used. ::: --- ## Event Schema Versioning Event classes can declare a **schema version** to track breaking changes to their properties. This allows consumers to perform version-aware deserialization or apply up-casting when replaying old events. ### With the source generator Set `Version` on `[Event]`: ```csharp [Aggregate] public partial class OrderAggregate : AggregateBase { public string CustomerId { get; private set; } = default!; public string Currency { get; private set; } = default!; // Version 1: original event (no currency) // [Event] ← implicitly Version = 1 // public partial void CreateOrder(string customerId); // Version 2: added Currency field [Event(Version = 2)] public partial void CreateOrder(string customerId, string currency); } ``` The generator emits a `[EventContract]` record named `OrderCreatedEvent` with `public static int SchemaVersion => 2;`. ### Manually Mark a hand-written event contract with `[EventContract]` and declare a static `SchemaVersion`: ```csharp [EventContract] public sealed record OrderCreatedEvent { public string CustomerId { get; set; } = default!; public string Currency { get; set; } = default!; public static int SchemaVersion => 2; } ``` The `SchemaVersion` is persisted as event metadata (a row column), not inside the payload. When the event is replayed from the store the version is rehydrated into `Metadata` and available via `@event.Metadata.SchemaVersion`, enabling conditional up-casting: ```csharp void Apply(OrderCreatedEvent e) { CustomerId = e.CustomerId; // Up-cast: v1 events did not have Currency; default to "GBP" Currency = e.Metadata.SchemaVersion >= 2 ? e.Currency : "GBP"; } ``` --- ## SQL Transaction Coordination Use `ISqlServerEventStoreTransactionFactory` when you need one SQL Server transaction that includes: - multiple enlisted aggregates saved through SQL Server event stores, - extra ad-hoc SQL commands (for example audit/outbox inserts), - EF Core operations against the same SQL connection boundary. ### Registration and usage `AddSqlServerEventStore()` registers `ISqlServerEventStoreTransactionFactory`. ```csharp public sealed class CheckoutService( ISqlServerEventStoreTransactionFactory sqlTransactionFactory, IEventStore store) { public async Task CheckoutAsync(OrderAggregate order, CancellationToken cancellationToken) { await using var tx = sqlTransactionFactory.CreateSqlServerTransaction(); tx.Enlist(order, store); tx.Enlist(async (connection, sqlTransaction, token) => { await using var cmd = new SqlCommand( "INSERT INTO dbo.TransactionAudit(CorrelationId, Value) VALUES (@c, @v)", connection, sqlTransaction); cmd.Parameters.AddWithValue("@c", tx.CorrelationId); cmd.Parameters.AddWithValue("@v", "checkout"); await cmd.ExecuteNonQueryAsync(token); }); var result = await tx.CommitAsync(cancellationToken); if (!result.Success) throw new InvalidOperationException("Transaction failed."); } } ``` ### Notes and limits - SQL-native atomic commit is available when enlisted stores share the same SQL transaction boundary. - Enlisting stores with different SQL transaction boundaries is rejected up front. - If you need cross-database/distributed coordination, implement a custom transaction coordinator strategy. - The SQL-specific coordinator requires at least one enlisted aggregate (the aggregate store establishes the connection/transaction boundary). - `IEventStoreTransactionFactory` remains available and unchanged for provider-agnostic transaction orchestration. ### Integration coverage `src/tests/SqlServer.IntegrationTests/Events/SqlServerEventStoreTransactionIntegrationTests.cs` verifies: - aggregate + raw SQL operation commit in one transaction, - aggregate + EF operation commit in one transaction, - rollback of both aggregate and enlisted SQL when an enlisted operation throws, - cross-implementation enlistment (event store + SQL snapshot event store) with additional SQL operations in one transaction. --- ## JSON Index Configuration SQL Server stores event and snapshot payloads in a JSON column named `Payload`. You can optionally configure additional runtime-managed indexes over scalar JSON paths. The provider creates these indexes only when: - `AutoCreateTable = true`, and - `JsonIndexOptions.Enabled = true`. The current implementation materializes each configured path as a computed column and then creates an index over that column. ### Supported configuration shape ```csharp public sealed class SqlServerJsonIndexOptions { public bool Enabled { get; set; } public SqlServerJsonIndexDefinition[] Indexes { get; set; } = []; } public sealed class SqlServerJsonIndexDefinition { public string JsonPath { get; set; } = default!; public string? IndexName { get; set; } public string? ComputedColumnName { get; set; } public string SqlType { get; set; } = "nvarchar(450)"; public bool Unique { get; set; } public SqlServerJsonComputedColumnMode ComputedColumnMode { get; set; } = SqlServerJsonComputedColumnMode.Persisted; public string[] IncludeColumns { get; set; } = []; public string? Filter { get; set; } } ``` ### Snapshot store example ```csharp builder.Services.AddSqlServerSnapshotQueryableEventStore(); builder.Services.Configure(options => { options.ConnectionString = "Server=.;Database=MyApp;Trusted_Connection=True;"; options.JsonIndexOptions.Enabled = true; options.JsonIndexOptions.Indexes = [ new SqlServerJsonIndexDefinition { JsonPath = "$.StringProperty", SqlType = "nvarchar(450)", IncludeColumns = ["Id"], }, new SqlServerJsonIndexDefinition { JsonPath = "$.IncrementInt32", SqlType = "int", }, ]; }); ``` ### Event store example ```csharp builder.Services.AddSqlServerEventStore(); builder.Services.Configure(options => { options.ConnectionString = "Server=.;Database=MyApp;Trusted_Connection=True;"; options.JsonIndexOptions.Enabled = true; options.JsonIndexOptions.Indexes = [ new SqlServerJsonIndexDefinition { JsonPath = "$.Value", SqlType = "nvarchar(450)", IncludeColumns = ["AggregateId", "Version"], Filter = "[EntityType] = 1", }, ]; }); ``` ### appsettings.json example ```json { "EventStore:SqlServerSnapshot": { "ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;", "SchemaName": "dbo", "TableName": "EventStoreSnapshots", "JsonIndexOptions": { "Enabled": true, "Indexes": [ { "JsonPath": "$.StringProperty", "SqlType": "nvarchar(450)", "IncludeColumns": ["Id"] } ] } } } ``` ### Rules and limitations - `JsonPath` must start with `$`. - `SqlType` must be a supported scalar SQL type expression such as `nvarchar(450)` or `int`. - Filter expressions are intentionally restricted; unsafe SQL text is rejected during startup. - Include columns are validated against the store schema. - Indexes are **created**, but not dropped or reconciled, by the runtime. - When `AutoCreateTable = false`, configured JSON indexes are not created automatically. ### Query-shape guidance Indexes help only when the predicate path is SQL-translatable. - Good candidates: - `a => a.StringProperty == value` - `a => a.ReportSummaryScalar!.ParserDetails.FailedLines > 0` - Poor candidates: - `a => a.ReportSummary!.Value.ParserDetails.FailedLines > 0` when `ReportSummary` is a `[Scalar]` wrapping a complex inner type If deep filtering matters, prefer directly mapped complex mirror properties on the aggregate snapshot model and test the exact predicate path. --- ## Snapshot Payload Shape Snapshot payload is the fully serialized aggregate graph stored in a JSON payload column. Supported members include: - writable primitive members, - `[Scalar]` value objects, - complex objects composed of supported members, - `EventStoreList` / `EventStoreSet` collections of supported primitive/complex members. Important distinction for SQL translation: - A `[Scalar]` value object with a **primitive** inner value behaves like a scalar in queries. - A `[Scalar]` value object with a **complex** inner value is persisted correctly, but deep predicates through `.Value` are not guaranteed to translate in SQL snapshot queries. - If you need deep SQL predicates for a complex concept, expose the underlying complex type directly on the aggregate/query snapshot model (for example, a `ParserReportSummary` mirror property) and test the exact nested predicate you expect to support. - Directly mapped complex snapshot members can support deep predicates such as `ParserDetails.FailedLines > 0`, subject to the provider's supported payload-shape rules. Value-type (struct) value objects mapped into the payload must be materializable by EF: - Declare a parameterless constructor (the `Purview.ValueObjects` generator emits one for `[ValueObject]` structs) or use only scalar constructor parameters. EF cannot bind complex or collection constructor parameters during JSON materialization and would otherwise fail snapshot query compilation (`EVENTSTOREEF003`). - Give every complex member declared on a value type an `init` or `set` accessor. EF cannot assign a read-only member of a value type (`EVENTSTOREEF004`). The provider rejects the shape while building the snapshot query model, so a read-only complex member on a value type fails with `InvalidOperationException` naming the member instead of EF's internal `ArgumentException: Expression must be writeable` at query time. The snapshot query model writes members through their property accessors (`PropertyAccessMode.PreferProperty`), so `readonly record struct` value objects — including value objects nested inside other value objects — round-trip and remain queryable. Unsupported members fail during model creation, including: - arrays, - collection types other than `EventStoreList` / `EventStoreSet` (for example `List`, `IReadOnlyList`, `IEnumerable`, `HashSet`, `ImmutableArray`), - unsupported object types that are not explicitly mapped for JSON conversion. Read-only and `[JsonIgnore]` members are excluded from snapshot payload mapping, and `[EFOpaque]` members are persisted as converted JSON scalars that are excluded from the queryable complex graph. Some nested collection/dictionary members inside directly mapped complex graphs may be supported through provider JSON conversion rather than direct relational collection mapping. Treat those shapes as provider-specific and verify them with integration tests. ### Examples ```csharp // Supported snapshot shape public sealed class CustomerSnapshot { public string Name { get; set; } = string.Empty; public EmailAddress Email { get; set; } = EmailAddress.Hydrate("demo@example.com"); // [Scalar] public EventStoreList Items { get; set; } = []; public EventStoreSet Tags { get; set; } = []; } ``` ```csharp // Unsupported snapshot shape (model validation fails) public sealed class CustomerSnapshot { public List Items { get; set; } = []; // use EventStoreList public string[] Labels { get; set; } = []; // arrays unsupported public Dictionary Metadata { get; set; } = []; // dictionaries unsupported } ``` For generator/framework behavior (aggregate inheritance paths, hooks, event naming/namespace, manual mode), see [Source Generator Behaviors](../source-generator-behaviors/). --- ## Behavior Notes and Caveats - `IsDeletedAsync` throws when the aggregate does not exist (it does not return `false` for missing aggregates). - Event replay is tolerant by default: unknown or unappliable events are skipped, and stream version continues to advance. - Integration coverage includes replay compatibility scenarios for: - **Unknown events** (event type name no longer resolvable): replay skips affected records and continues. - **Schema-change style evolution** (event type still deserializes but is no longer applied/registered): replay logs `CannotApplyEvent` and continues. - See: `src/tests/SqlServer.IntegrationTests/Guards/SqlServerEventStoreGuardTests.cs` and the shared contract suites in `src/tests/SharedTestingFramework/Contracts/EventStoreContractTestsBase.cs`. - Principal enforcement is enabled by default (`RequiresValidPrincipalIdentifier = true`), so save operations require the configured claim identifier to be present on the current principal. --- ## Connection String Examples ### Local development (Windows auth) ```text Server=(localdb)\MSSQLLocalDB;Database=MyApp;Trusted_Connection=True; ``` ### SQL Server with SQL auth ```text Server=my-server.database.windows.net;Database=MyApp;User Id=app_login;Password=…; ``` ### Azure SQL with Managed Identity ```text Server=my-server.database.windows.net;Database=MyApp;Authentication=Active Directory Default; ``` ### Azure SQL with connection string from Key Vault ```json { "EventStore:SqlServer": { "ConnectionString": "@Microsoft.KeyVault(SecretUri=https://my-vault.vault.azure.net/secrets/SqlConnection)" } } ``` --- # Release flow This repository uses the shared [Purview.Build](https://github.com/purview-dev/build) pipeline for both PR validation and releases. Consuming repositories own configuration (through `purview-build.json`) but not pipeline source code. - `.github/workflows/pr.yml` — PR validation - `.github/workflows/release.yml` — release on push to `main` - `purview-build.json` — pipeline configuration ## PR validation `pr.yml` runs on pull requests targeting `main` and delegates to the shared `purview-build.yml` workflow. It runs: 1. `dotnet restore` of `src/EventSourcing.slnx` 2. `dotnet build --no-restore --configuration Release` 3. CSharpier lint across the repository 4. Unit tests (discovered under `src/tests` matching `*Tests.csproj`, run with the `/*/*/*/*[Category=Unit]` TUnit tree-node filter) 5. `dotnet pack` and package-content validation Integration tests are never discovered in CI: `purview-build.json` sets `Build:TestPatterns` to `*Tests.csproj`, `Build:TestProjects` to `*UnitTests.csproj`, and `Build:TestFilter` to `/*/*/*/*[Category=Unit]`, so only unit-test projects (tagged `[Category=Unit]` by the `Purview.BuildSdk`) are executed; provider integration tests (which require Docker/Testcontainers) run only locally via `just test`. The performance harnesses live under `src/src/Benchmarks` (a single non-test `Benchmarks.csproj`) and run locally via `just perf-source-generator` / `just perf-runtime` / `just perf-sql-server`. The PR workflow does not tag, release, or publish packages. ## Versioning model `package.json` is the authoritative release version source. The release workflow reads: ```bash bun -p "require('./package.json').version" ``` This flow assumes version prep already happened before release (for example with `@changesets/cli` versioning and changelog updates merged to `main`). The release pipeline does not invent or auto-bump versions. ## Release on push to main `release.yml` triggers on push to `main` and delegates to the shared `purview-release.yml` workflow with `release-mode: NuGet`. The shared workflow: 1. Reads `package.json` `version` and computes the `v` tag. 2. Skips the entire release if `v` already exists (so re-merging to `main`, or merging `main` into a `release` branch, releases exactly once). 3. Restores, builds, lints, runs unit tests, packs, and validates packages. 4. Pushes every `.nupkg` to nuget.org (`--skip-duplicate`). 5. Creates the `v` GitHub release with generated release notes and attaches the package artifacts. A release is therefore produced simply by bumping `package.json` (via changesets) and merging to `main`. Do not create release tags manually. ## Prerelease support Prerelease versions (any SemVer containing a hyphen, for example `2.0.0-prerelease.29`) release through the same push-to-`main` flow. The `v` tag and GitHub release are still created and packages published; the shared pipeline does not mark the GitHub release with the prerelease flag. ## NuGet publishing NuGet publishing uses the shared workflow's API-key path with the organization `NUGET__APIKEY` secret (available through `secrets: inherit`). The pipeline also accepts `NUGET_APIKEY`. No long-lived repository-level API key secrets are required. To use NuGet Trusted Publishing (OIDC) instead, the consuming repository would need to mint the federated credential before the shared pipeline runs; the shared workflow itself does not perform the `NuGet/login` step. ## Shared pipeline configuration `purview-build.json` at the repository root drives the pipeline: | Key | Value | Purpose | | --- | --- | --- | | `Build:Solution` | `src/EventSourcing.slnx` | Solution passed to restore/build/pack | | `Build:TestRoot` | `src/tests` | Test project discovery root | | `Build:TestPatterns` | `*Tests.csproj` | Test projects discovered for the test step | | `Build:TestProjects` | `*UnitTests.csproj` | Restricts the run list to unit-test projects (excludes integration tests and the `src/src/Benchmarks` harnesses) | | `Build:TestFilter` | `/*/*/*/*[Category=Unit]` | TUnit tree-node filter (unit-only) | | `PackValidation:RequireSymbolPackage` | `true` | Every `.nupkg` needs a matching `.snupkg` | | `PackValidation:RequireSymbolFiles` | `true` | Every `.snupkg` must contain PDBs | | `PackValidation:RequiredContent` | Expected package contents | Asserts each package ships its expected output — README/logo, `buildTransitive/Purview.EventSourcing.targets` in the core package, `buildTransitive/Purview.EventSourcing.Validation.ZodSharp.targets` in the Purview.ZodSharp package, the analyzer assemblies in the core/EF-Core-enabled packages, and the provider/admin `lib` assemblies | | `Release:Mode` | `None` | Publishing is enabled only by the release workflow | Configuration precedence is command line, environment variables, `purview-build.json`, then the tool's built-in defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`.