CQRS and Event Sourcing for .NET 10. Start with a mediator, grow into the full stack, keep the receipts.
New to the terms? Mediator — your controller hands over one object (OpenAccount) and a dispatcher finds the single handler that answers it. CQRS — Command Query Responsibility Segregation: a command changes something and returns little, a query reads and changes nothing, and keeping them apart lets each side take the shape its own job needs. Event sourcing — store the facts that happened (AccountOpened, MoneyDeposited) instead of the state they produced, and fold them to get the current value. Longer: the glossary.
Stratara is one MIT-licensed family of 25 NuGet packages, versioned together: mediator, event store on PostgreSQL, outbox over RabbitMQ or Azure Service Bus, projections, sagas, identity — and, as defaults rather than add-ons, hash-chained tamper-evident event streams and tenant-bound field encryption with GDPR-grade crypto-shredding. Take one package or take all of them; they never disagree about each other's version.
Commands, queries and pipeline behaviors, in process. One package; no database, no broker, no telemetry setup.
dotnet add package Stratara.Mediatorpublic sealed record OpenAccount(string Owner, decimal Initial) : ICommand<Guid>;
public sealed class OpenAccountHandler : IQueryHandler<OpenAccount, Guid>
{
public Task<Guid> HandleAsync(OpenAccount cmd, CancellationToken ct)
=> Task.FromResult(Guid.NewGuid());
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMediator()
.AddQueryHandlersFromAssemblyContaining<Program>();
var app = builder.Build();
using var scope = app.Services.CreateScope(); // IMediator is scoped
var id = await scope.ServiceProvider.GetRequiredService<IMediator>()
.HandleAsync(new OpenAccount("Alice", 100m));→ First Stratara app · samples/Stratara.Sample.CqrsBasics
Aggregates and events on PostgreSQL, snapshots, optimistic concurrency, an outbox, push-driven projections and replay. You write the aggregate; the store, the outbox and the workers ship.
dotnet add package Stratara.EventSourcing.WorkerDefaultspublic sealed record InvoiceIssued(Guid InvoiceId, Guid TenantId, decimal Total) : IAggregateCreationEvent;
public sealed class Invoice : ITenantAggregate
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public decimal Total { get; set; }
public void Apply(InvoiceIssued e) => (Id, TenantId, Total) = (e.InvoiceId, e.TenantId, e.Total);
}
// inside a command handler
await events.CreateAsync<Invoice>(id, new InvoiceIssued(id, tenantId, 120m), ct);
await events.SaveChangesAsync(ct); // one transaction, snapshot by policy, one bundle to the outbox→ Write a command handler · Write a projection · Event-sourced walkthrough
Every event stream is hash-chained, with periodic anchors you can commit outside your database. [EncryptData] fields are sealed with the tenant as associated data, so a row leaked from one tenant cannot be read in another, even with the master key. Erasure is a key you destroy, not history you rewrite.
public sealed record CustomerRegistered(
Guid CustomerId,
Guid TenantId,
[property: EncryptData] string Email) : IAggregateCreationEvent;
// GDPR Art. 17: shred the subject's key — events, snapshots, replicas and backups become noise
await keyStore.EraseScopeAsync(scope, ct);→ Tamper-evident streams · Tenant-aware encryption · hero samples TamperProof and Encryption
The handler from door one runs unchanged behind door three. Only the hosting around it changes.
flowchart LR
subgraph S1["1 · Mediator only"]
A0["API host<br/>IMediator + handlers"]
end
subgraph S2["2 · + Event store"]
A1["API host<br/>IMediator + IEventSource"] --> DB1[("PostgreSQL<br/>streams · snapshots · outbox")]
end
subgraph S3["3 · + Workers and bus"]
A2["API hosts 1..N"] --> BUS{{"RabbitMQ / Azure Service Bus"}}
BUS -->|competing consumers| CW["Command workers"]
CW --> DB2[("PostgreSQL<br/>4096 stream buckets")]
DB2 -->|pushed event bundles| PW["Projection · saga workers"]
PW --> RM[("Read models")]
end
S1 -.-> S2 -.-> S3
- Integrated, not assembled. Mediator, outbox, event store, sagas, projections and identity are one family at one version. No composition tax, no version-skew puzzles.
- Audit-grade by default. Hash-chained streams with periodic anchors ready for external commitment; a direct edit in the database no longer recomputes, and a verification pass names the sequence where it broke.
- Tenant isolation you can prove. Cryptographic binding of encrypted fields to their tenant, plus a mediator-entrance guard that rejects a request naming another tenant before your handler runs.
- Erasure without rewriting history. Per-subject keys;
EraseScopeAsyncmakes every copy undecryptable, including the backups you cannot reach. - Fast and horizontal. Reflection-free hot paths, push-driven projections, deterministic stream buckets so workers scale out as competing consumers.
Measured with BenchmarkDotNet on a fanless MacBook Air M4 (.NET 10, Arm64). Conservative ratios, not a tuned server's ceiling. Re-run: dotnet run -c Release --project tests/Stratara.Benchmarks -- --filter '*'.
| What | Result |
|---|---|
| Replay 1,000,000 events in memory | 11.6 ms, 64 B allocated |
| Replay 10,000 events | 0.11 ms, 64 B |
| Property write, compiled delegate vs reflection | 0.47 ns vs 6.04 ns, ~13× faster, allocation-free |
| Tamper-evident chain hashing | sub-microsecond per event |
Methodology and caveats: Performance & scaling.
stratara.tech — concepts, getting started, guides, sample walkthroughs, the package map and the generated API reference. Every guarantee is written down as a specification under openspec/specs/ and tested in CI; the docs are derived from those specs.
Using an AI assistant? Point it at llms.txt (orientation) and llms-full.txt (every registration, option and exception, generated from the assemblies), or connect any MCP-capable client to gitmcp.io/yesbert/Stratara.
Self-contained concept samples, each running in under a second: a five-step learning path on one bank-account domain (CqrsBasics → EventSourced → OutboxWorker → MoneyTransferSaga → AspNetCoreApi), two hero samples (TamperProof, Encryption), plus Validation, Identity and IdentityDirectory. See samples/ and the walkthroughs.
dotnet run --project samples/Stratara.Sample.TamperProofRequires the .NET 10 SDK (global.json pins it).
dotnet build Stratara.Publish.slnf -c Release
./scripts/local-gauntlet.sh # what CI runsLockstep across the family — one <VersionPrefix> in Directory.Build.props. A v* tag publishes to nuget.org and nothing else does; prereleases are tagged v4.0.0-preview.1 and reach only those who ask. Per-release notes: CHANGELOG.md.
MIT — see LICENSE. Free for any use, including commercial; no competition clause, no time delay.
Issues, questions and pull requests are welcome: open an issue, read CONTRIBUTING.md, run ./scripts/local-gauntlet.sh before a PR. Security issues go through SECURITY.md, not a public issue. Community standards: CODE_OF_CONDUCT.md; getting help: SUPPORT.md.
The repository was mirrored from a private one until 2026-08-30, one squashed commit per release; development happens here now.