CQRS and Event Sourcing for .NET 10. Start with a mediator, scale out on Orleans, 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 28 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. Scale out on the Orleans execution model: one writer per aggregate across the cluster, and no accepted command lost to a crash. 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 Orleans execution model runs your handlers, projections, sagas and timers as virtual actors on a cluster. A committed fact is never lost to a crash. One aggregate has one writer across the whole deployment. Work that must happen once per cluster needs no lock. Your handlers do not change; the registration does.
dotnet add package Stratara.Orleans
dotnet add package Stratara.Orleans.EntityFrameworkCorebuilder.UseOrleans(silo => silo
.UseAdoNetClustering(o => { o.Invariant = "Npgsql"; o.ConnectionString = orleansDb; })
.UseAdoNetReminderService(o => { o.Invariant = "Npgsql"; o.ConnectionString = orleansDb; })
.AddStrataraOrleans((s, name) => s.AddRedisGrainDirectory(name, o => o.ConfigurationOptions = redis)));
builder.Services
.AddStrataraAggregateGrains() // one writer per aggregate, cluster-wide
.AddStrataraOrleansCommandDispatcher() // an accepted command is recorded before the call returns
.AddStrataraIntentStore<AppWriteDbContext>() // ... and resumed after a crash
.AddStrataraSingletonWork<OutboxDrainWork>(OutboxDrainWork.WorkName); // once per cluster, without a lockTest the same registrations in one process, without a cluster or Docker: Stratara.Testing.Orleans runs a silo in memory — see the sample OrleansExecutionModel.
→ The Orleans execution model · Choose an execution model · Migrate from the bus workers
The handler from door one runs unchanged behind every later stage. Only the hosting around it changes. At stage three you choose what runs it: the Orleans execution model, which is recommended, or the bus workers, which stay supported.
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 · + Orleans cluster (recommended)"]
SI["Silos 1..N<br/>aggregate · projection · saga grains"] --> DB3[("PostgreSQL<br/>streams · recorded commands · checkpoints")]
DB3 -->|commit order| SI
end
subgraph S3B["3 · + Workers and bus (supported)"]
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
S2 -.-> S3
S2 -.-> S3B
- 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.
The Orleans execution model against the bus workers, same machine, one process each, PostgreSQL, Redis and RabbitMQ in containers; each command's handler appends to its aggregate's stream. Medians of three runs of 2,000 commands. The bus workers' own numbers moved by up to 23 % between runs on the same day, so read the ratios as approximate.
| What | Orleans execution model | Bus workers |
|---|---|---|
| Commands to 2,000 aggregates, one each, recorded durably before the call returns | 1,443 /s | 487 /s |
| Commands to 20 aggregates, 100 each | 1,320 /s | 506 /s |
| 2,000 commands to one aggregate — a single writer is the limit on both | 316 /s | 321 /s |
| Processor time per 1,000 commands, one host | 2.44 CPU-s (+7 %) | 2.27 CPU-s |
| Memory at idle / under 200 commands/s | 190 / 217 MB | 167 / 185 MB |
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.