Event sourcing in .NET with Chronicle: from zero to first projection
Event sourcing has a reputation for heavy setup: a store, a bus, projections infrastructure, and a day of wiring before the first event lands. This post takes the shortest honest path instead: one Docker container, one console project, and about ninety lines of C# that append events, project them into read models, and react to them — with the full event history inspectable in a browser at the end.
Everything below was executed as written. The versions are pinned so you can reproduce the run exactly:
| Piece | Version |
|---|---|
| Chronicle kernel container | cratis/chronicle:latest-development, digest sha256:a423610f88e088e53d3bb821fa972a35dc84e254297bf8d720653316b68e61bf (Chronicle Server 16.38.5) |
| Client package | Cratis.Chronicle 16.38.5 |
| .NET SDK | 8.0.408 (net8.0 target) |
| Extra package on .NET 8 | System.Collections.Immutable 10.0.0 (see the note in step 2) |
latest-development is a moving tag; if you pull it later you may get a newer kernel. The digest above is the exact image this post was verified against. Chronicle and its bundled local Workbench are MIT-licensed, self-hosted software — what you run here is yours to run.
What you will build
Section titled “What you will build”A minimal .NET console application for a tiny library domain: a book arrives, gets borrowed, and comes back. Each of those facts is an event appended to Chronicle’s event log. Two read models are projected from those events — declaratively, with no update code — and a reactor performs a side effect when a book is returned. At the end you open the bundled Workbench and see the whole history.
1. Run Chronicle
Section titled “1. Run Chronicle”The development image bundles the Chronicle kernel and its MongoDB storage in a single container — no separate database setup. Start it bound to loopback only, so nothing outside your machine can reach it:
docker run -d --name chronicle \ -p 127.0.0.1:35000:35000 \ cratis/chronicle:latest-developmentPort 35000 carries gRPC, the REST API, and the Workbench on a single TLS port, using a self-signed development certificate the container generates at startup. Give it a few seconds, then confirm it is up:
docker logs chronicle 2>&1 | grep "Starting Cratis Chronicle Server"Starting Cratis Chronicle Server - Version 16.38.5.02. Create the application
Section titled “2. Create the application”Create a console project and add the Chronicle client at the pinned version:
mkdir Quickstart && cd Quickstartdotnet new console --framework net8.0dotnet add package Cratis.Chronicle --version 16.38.5dotnet add package System.Collections.Immutable --version 10.0.0Why the extra package? On a
net8.0target,Cratis.Chronicle16.38.5 fails at startup withCould not load file or assembly 'System.Collections.Immutable, Version=10.0.0.0'unless you add the package explicitly — the client assembly references it, but the package does not declare the dependency. On the .NET 10 SDK with anet10.0target andCratis.Chronicle17.0.0, the extra package is not needed; we verified both combinations against the same kernel.
3. Define the events
Section titled “3. Define the events”Events are immutable facts, modeled as records marked with [EventType]. The attribute is how Chronicle discovers the type — the type name is the identity, so there is nothing else to configure:
using Cratis.Chronicle.Events;
[EventType]public record BookAdded(string Title, string Isbn);
[EventType]public record BookBorrowed(string MemberName);
[EventType]public record BookReturned;BookReturned carries no data at all. That it happened, on a particular book’s stream, is the whole story — not every fact needs a payload.
4. Declare the read models
Section titled “4. Declare the read models”Events are the write side. To read current state, you declare the shape you want and which events feed each field, and Chronicle keeps it in sync — you never write an UPDATE:
using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
[FromEvent<BookAdded>]public record Book( [Key] Guid Id,
string Title,
string Isbn,
[SetValue<BookAdded>(false)] [SetValue<BookBorrowed>(true)] [SetValue<BookReturned>(false)] bool OnLoan,
[SetFrom<BookBorrowed>(nameof(BookBorrowed.MemberName))] string? BorrowedBy);Read the attributes as a sentence: a book enters the view from BookAdded; OnLoan flips with each borrow and return; BorrowedBy is whoever borrowed it. Title and Isbn map from the event by naming convention — no per-property attributes needed when the names match.
The second read model answers “what is out on loan right now?” by existing only while a loan is active:
[FromEvent<BookBorrowed>][RemovedWith<BookReturned>]public record BorrowedBook( [Key] Guid Id,
string MemberName);When a BookBorrowed lands, a BorrowedBook appears; when the matching BookReturned arrives, it is removed. No flag to maintain, no filter to remember.
5. React to an event
Section titled “5. React to an event”When you need to do something the moment a fact lands — notify someone, call another system — you write a reactor. IReactor is a marker interface; add a method whose first parameter is the event you care about, and Chronicle routes matching events to it:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
public class BookReturnedNotifier : IReactor{ public Task Returned(BookReturned @event, EventContext context) { Console.WriteLine($"Reactor: book {context.EventSourceId} was returned — notify the next member in line."); return Task.CompletedTask; }}6. Connect, append, and query
Section titled “6. Connect, append, and query”Now the program that ties it together. In a console app there is no host or DI container, so you create the ChronicleClient yourself, open an event store, and explicitly ask Chronicle to discover and register the artifacts you just defined:
using Cratis.Chronicle;using Cratis.Chronicle.Connections;
using var client = new ChronicleClient(ChronicleConnectionString.Development);var eventStore = await client.GetEventStore("Quickstart");Console.WriteLine($"Connected to event store: {eventStore.Name}");
await eventStore.DiscoverAll();await eventStore.RegisterAll();
var bookId = Guid.NewGuid();
var appendResult = await eventStore.EventLog.Append( bookId, new BookAdded("The Pragmatic Programmer", "978-0135957059"));Console.WriteLine($"Appended BookAdded at sequence {appendResult.SequenceNumber} (success: {appendResult.IsSuccess})");
appendResult = await eventStore.EventLog.Append(bookId, new BookBorrowed("Jane Doe"));Console.WriteLine($"Appended BookBorrowed at sequence {appendResult.SequenceNumber} (success: {appendResult.IsSuccess})");
// Give the freshly registered read models a moment to come online before the first query.await Task.Delay(TimeSpan.FromSeconds(5));
var books = await eventStore.ReadModels.GetInstances<Book>();foreach (var book in books){ Console.WriteLine($"Book read model: {book.Title} ({book.Isbn}) OnLoan={book.OnLoan} BorrowedBy={book.BorrowedBy}");}
var borrowed = await eventStore.ReadModels.GetInstances<BorrowedBook>();foreach (var loan in borrowed){ Console.WriteLine($"BorrowedBook read model: {loan.Id} borrowed by {loan.MemberName}");}
appendResult = await eventStore.EventLog.Append(bookId, new BookReturned());Console.WriteLine($"Appended BookReturned at sequence {appendResult.SequenceNumber} (success: {appendResult.IsSuccess})");
// Give the reactor a moment to observe the event.await Task.Delay(TimeSpan.FromSeconds(5));
books = await eventStore.ReadModels.GetInstances<Book>();foreach (var book in books){ Console.WriteLine($"Book read model after return: {book.Title} OnLoan={book.OnLoan}");}
borrowed = await eventStore.ReadModels.GetInstances<BorrowedBook>();Console.WriteLine($"BorrowedBook read models after return: {borrowed.Count()}");ChronicleConnectionString.Development points at the local development kernel on chronicle://localhost:35000 with the built-in development credentials — the same connection new ChronicleClient() uses with no arguments. The event source id (bookId) is the identity of the thing each fact is about; every event appended against it becomes part of that book’s stream of history.
The two Task.Delay calls deserve honesty: GetInstances replays events on demand, but registration of freshly declared read models and delivery to reactors are asynchronous. On our machine, querying immediately after the very first registration returned empty results; five seconds was comfortably enough. In a long-running application this is a non-issue — registration happens once at startup.
Put the event, read model, and reactor definitions after the top-level statements (or in separate files) and run it:
dotnet runConnected to event store: QuickstartAppended BookAdded at sequence 0 (success: True)Appended BookBorrowed at sequence 1 (success: True)Book read model: The Pragmatic Programmer (978-0135957059) OnLoan=True BorrowedBy=Jane DoeBorrowedBook read model: c6dc1472-3d85-4dd9-97bf-60e009e29caf borrowed by Jane DoeAppended BookReturned at sequence 2 (success: True)Reactor: book c6dc1472-3d85-4dd9-97bf-60e009e29caf was returned — notify the next member in line.Book read model after return: The Pragmatic Programmer OnLoan=FalseBorrowedBook read models after return: 0That is the whole loop — append, project, react. The book’s OnLoan flag flipped, the BorrowedBook appeared and disappeared, and the reactor fired — and you never wrote an update statement.
7. See the history
Section titled “7. See the history”State-based storage shows you what the data is. Chronicle also shows you every fact that made it so. Open the bundled Workbench at https://localhost:35000 — your browser will warn about the self-signed development certificate; that is expected for the local development image. Log in with the development image’s default credentials (username Admin, password ChangeMeNow! — see Workbench development mode), pick the Quickstart event store, and select Sequences: your BookAdded, BookBorrowed, and BookReturned are sitting there in order, permanent, with their event source id and timestamps. The Workbench is a bundled local browser surface for authorized inspection of Chronicle runtime state — run the program again and watch new events arrive.
Clean up
Section titled “Clean up”When you are done, remove the container and the image:
docker rm -f chronicledocker rmi cratis/chronicle:latest-developmentDeleting your Quickstart folder removes everything else — event data lives inside the container, so removing it removes the data too.
Where to go next
Section titled “Where to go next”- The console quickstart covers this same path in the documentation, including querying the materialized read models in MongoDB directly.
- The tutorial builds the library domain one concept at a time — strongly-typed ids, hosts, and DI included.
- The ASP.NET Core and Worker Service guides show the same pieces with the host’s DI container doing the wiring.
- Chronicle also ships TypeScript, Kotlin/Java (JVM), and Elixir clients — with a Python client coming soon (no commitment implied) — so the event log is not a .NET-only story.