# 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`.