Skip to content

Add Chronicle to an ASP.NET Core app

When your app is a web API, Chronicle fits the way you already build: it plugs into the WebApplicationBuilder, registers itself in the dependency-injection container, and lets your endpoints append events by taking IEventLog as a dependency. There’s almost no glue — a couple of calls in Program.cs and your routes can start recording facts.

We’ll build a small library domain and expose an endpoint that borrows a book. If you’re not building a web app — a background processor or scheduled host — the Worker Service guide covers that host instead, and the console guide shows the bare-bones version with no container at all.

Have the Chronicle kernel running locally. Run Chronicle locally brings it up with a single docker run and lists the prerequisites (.NET 8+, Docker); this guide assumes it’s listening on chronicle://localhost:35000.

You can also find the complete ASP.NET Core quickstart sample on GitHub.

Create a folder for your project, then a .NET web project inside it:

Terminal window
dotnet new web

Add a reference to the Chronicle ASP.NET Core package (it brings in the base Chronicle package for you):

Terminal window
dotnet add package Cratis.Chronicle.AspNetCore

ASP.NET Core builds your app through the WebApplicationBuilder, which already has a dependency-injection container. Chronicle hooks straight into it — two calls in Program.cs are the entire integration:

var builder = WebApplication.CreateBuilder(args)
.AddCratisChronicle(options => options.EventStore = "Quickstart");
var app = builder.Build();
app.UseCratisChronicle();

AddCratisChronicle registers Chronicle’s services and names the event store to use; UseCratisChronicle hooks it into the request pipeline. Unlike the bare-bones console version, all discovery and registration of your artifacts happens automatically — the container finds your reactors, reducers, and projections for you.

AddCratisChronicle

UseCratisChronicle

IEventLog.Append

WebApplicationBuilder

DI container

(auto-discovers artifacts)

app

request pipeline

minimal API / controller

event store

Everything in Chronicle starts with a fact. You model facts as record types marked with [EventType] — records because an event, once it happened, never changes. The attribute is how Chronicle discovers the type; it takes no name, the type name is the identity.

Here are the facts of a small library — a book arrives, gets borrowed, and comes back:

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.

You record a fact by appending it to an event sequence. Chronicle gives you one by default — the event log, the main sequence you’ll use, much like the main branch of a Git repository. Reach it through the event store:

var eventLog = eventStore.EventLog;
var bookId = Guid.NewGuid();
await eventLog.Append(bookId, new BookAdded("The Pragmatic Programmer", "978-0135957059"));

That first argument is the event source id — the identity of the thing this fact is about, like a primary key. Every event you append against bookId becomes part of that book’s stream of history.

Run your app, then open the workbench, pick your event store, and select Sequences — your BookAdded is sitting there at sequence number 0, permanent and in order.

Chronicle Workbench showing events

Events are the write side — the source of truth. To read current state you don’t query the log directly; you let Chronicle fold the events into a read model for you. The declarative way to do that is a projection: you declare the shape you want and which events feed each field, and Chronicle keeps it in sync — no update code, ever.

using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[ReadModel]
public record Book(
[Key]
Guid Id,
[SetFrom<BookAdded>(nameof(BookAdded.Title))]
string Title,
[SetFrom<BookAdded>(nameof(BookAdded.Isbn))]
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’s Title and Isbn come from BookAdded; OnLoan is false when it’s added, true when borrowed, false again when returned; BorrowedBy is whoever borrowed it. You’re declaring how facts map onto the view — Chronicle replays the events in order and applies the mapping.

The projection writes Book to a store (MongoDB by default), so reading it is an ordinary query:

public class Books(IMongoCollection<Book> collection)
{
public IEnumerable<Book> OnLoan() => collection.Find(b => b.OnLoan).ToList();
}

Append a BookBorrowed against the same bookId, query again, and OnLoan is true with BorrowedBy set; append a BookReturned and it flips back. You never wrote an UPDATE.

Projections build state. When you need to do something the moment a fact lands — notify someone, call another system — you write a reactor. IReactor is a marker; you just 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 BookReturned(BookReturned @event, EventContext context)
{
// context.EventSourceId is the BookId this happened to
Console.WriteLine($"Book {context.EventSourceId} was returned — notify the next member in line.");
return Task.CompletedTask;
}
}

No registration, no wiring — drop the class in and every BookReturned flows to it. Reactors must be idempotent, because the same event may be delivered more than once (during a replay or a recovery). In a real app you’d inject a notification service here — the tutorial and the Reactors guide show that, along with how reactors get their dependencies under a host.

That’s the whole loop — append → project → react. The tutorial builds exactly this library one concept at a time and explains each as you go; the Projections, Reducers, and Reactors guides go deeper on each piece.

In a web app you usually append events from a route handler rather than inline. Take IEventLog as a dependency and append — the container injects it:

app.MapPost("/api/books/{bookId}/borrow", async (
[FromServices] IEventLog eventLog,
[FromRoute] Guid bookId,
[FromQuery] string memberName) =>
await eventLog.Append(bookId, new BookBorrowed(memberName)));

The bookId from the route is the event source — the book this fact is about — and memberName is the event’s payload. That one Append is all it takes; the projection and any reactors pick it up from there.

Chronicle creates its discovered artifacts — reactors, reducers, projections — through the container, so they need to be registered as services. For a handful, register them explicitly:

builder.Services.AddTransient<BookReturnedNotifier>();

As the solution grows this gets tedious, so Cratis Fundamentals can do it by convention:

builder.Services
.AddBindingsByConvention()
.AddSelfBindings();

AddBindingsByConvention registers any service that implements an interface of the same name prefixed with I (IFooFoo); AddSelfBindings registers concrete classes as themselves, so you can depend on them directly without registering each one.

The Books query reads documents Chronicle wrote, so the MongoDB driver needs to match how Chronicle stores them — register these conventions once at startup:

When leveraging the Reducer and Projection capabilities of Chronicle, your MongoDB Client needs to be configured to match how it produces documents and naming conventions. By adding the following code, you’ll have something that matches:

BsonSerializer
.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
var pack = new ConventionPack
{
// We want to ignore extra elements that might be in the documents, Chronicle adds some metadata to the documents
new IgnoreExtraElementsConvention(true),
// Chronicle uses camelCase for element names, so we need to use this convention
new CamelCaseElementNameConvention()
};
ConventionRegistry.Register("conventions", pack, t => true);

Snippet source

Then register the database and the collections you want to inject, so a type can take an IMongoCollection<Book> dependency without ever touching MongoClient:

builder.Services.AddSingleton<IMongoClient>(new MongoClient("mongodb://localhost:27017"));
builder.Services.AddSingleton(provider => provider.GetRequiredService<IMongoClient>().GetDatabase("Quickstart"));
builder.Services.AddTransient(provider => provider.GetRequiredService<IMongoDatabase>().GetCollection<Book>("book"));

Now the Books query from the read-model section above resolves its collection straight from the container.

You added Chronicle to an ASP.NET Core app with two lines in Program.csAddCratisChronicle to register and discover everything, UseCratisChronicle to hook into the pipeline — then appended events straight from a minimal API endpoint and read them back through MongoDB collections injected by the container. Because you’re in a DI world, your reactors, projections, and collections are all just registered services.

  • Put a typed UI on topArc adds commands, queries, and generated TypeScript proxies so React stays in lockstep with your C#. See Build a full-stack feature.
  • Build the domain step by step — the tutorial walks the library model one concept at a time.
  • A different host — the same artifacts run unchanged in a Worker Service or a bare console app.