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:

using Microsoft.AspNetCore.Builder;
public static class AspNetCoreRegistration
{
public static void ConfigureApp(string[] args)
{
var builder = WebApplication.CreateBuilder(args)
.AddCratisChronicle(options => options.EventStore = "Quickstart");
var app = builder.Build();
app.UseCratisChronicle();
}
}

View C# snippet source on GitHub

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 GetStartedBookAdded(string Title, string Isbn);
[EventType]
public record GetStartedBookBorrowed(string MemberName);
[EventType]
public record GetStartedBookReturned;

View C# snippet source on GitHub

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:

using Cratis.Chronicle;
public class GetStartedBookService(IEventStore eventStore)
{
public async Task<Guid> AddBook()
{
var eventLog = eventStore.EventLog;
var bookId = Guid.NewGuid();
await eventLog.Append(bookId, new GetStartedBookAdded("The Pragmatic Programmer", "978-0135957059"));
return bookId;
}
}

View C# snippet source on GitHub

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 GetStartedBookAdded is sitting there, permanent and in order. In a fresh event store the first event is at sequence number 0.

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;
[FromEvent<GetStartedBookAdded>]
public record GetStartedBook(
[Key]
Guid Id,
string Title,
string Isbn,
[SetValue<GetStartedBookAdded>(false)]
[SetValue<GetStartedBookBorrowed>(true)]
[SetValue<GetStartedBookReturned>(false)]
bool OnLoan,
[SetFrom<GetStartedBookBorrowed>(nameof(GetStartedBookBorrowed.MemberName))]
string? BorrowedBy);

View C# snippet source on GitHub

Read the attributes as a sentence: a book comes into the view 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.

One view rarely answers every question. The librarian’s next one is “what’s out on loan right now?” — and rather than filtering GetStartedBook, you declare a second, purpose-built read model whose very existence tracks the loan:

using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[FromEvent<GetStartedBookBorrowed>]
[RemovedWith<GetStartedBookReturned>]
public record GetStartedBorrowedBook(
[Key]
Guid Id,
string MemberName);

View C# snippet source on GitHub

The moment a GetStartedBookBorrowed lands, a GetStartedBorrowedBook instance appears — keyed by the book’s id, its MemberName mapped by the same naming convention. When the matching GetStartedBookReturned arrives, [RemovedWith<GetStartedBookReturned>] removes the instance from the view again. The collection always holds exactly the books that are out right now — no flag to maintain, no filter to remember, no cleanup job. Kotlin and Java don’t currently have an equivalent to [RemovedWith<T>].

The most direct way to look at a read model is to ask Chronicle itself. IReadModels — reached through eventStore.ReadModels — hands you every instance of a read model, one call per view:

using Cratis.Chronicle;
public class GetStartedBookQueryService(IEventStore eventStore)
{
public async Task<(IEnumerable<GetStartedBook> Books, IEnumerable<GetStartedBorrowedBook> BorrowedBooks)> QueryBooks()
{
var books = await eventStore.ReadModels.GetInstances<GetStartedBook>();
var borrowedBooks = await eventStore.ReadModels.GetInstances<GetStartedBorrowedBook>();
return (books, borrowedBooks);
}
}

View C# snippet source on GitHub

These instances come from the sink. By default Chronicle materializes every projection into the sink storage configured for the event store — MongoDB unless you change it. For the default namespace the database is named after the event store (Quickstart), and each collection after its read model, pluralized: GetStartedBooks and GetStartedBorrowedBooks. That makes a query cheap — a database fetch, whatever the length of the history.

Materialization happens in the background as events arrive, so these reads are eventually consistent. An append completes when the event is in the log, not when the read model has caught up, so a query issued straight after an append can miss it. That is most noticeable on the first event against a fresh event store: processing that event also creates the projection pipeline and its storage for the first time, so the read straight after that append is the one most likely to come back empty. Query again a moment later and the book is there.

When a read has to include the event you just appended, you have two tools. Mark the read model passive, and Chronicle keeps it out of the sink and computes it from its events on every read. Or keep it materialized and wait for the append’s observers to catch up before reading. Read model consistency compares the two.

Getting a collection of instances covers the details, including the event count you can pass to replay a read model from the beginning instead. Kotlin and Java currently only support fetching a single instance by key (getInstanceByKey) — fetching every instance of a read model isn’t available yet.

GetInstances returns every stored instance at once. The Materialized API reads the same stored instances back a page at a time. Kotlin and Java don’t currently have a materialized/paging query API at all; Elixir’s equivalent pages by page number rather than skip/take:

using Cratis.Chronicle;
public class GetStartedBookPagingService(IEventStore eventStore)
{
public async Task<IEnumerable<GetStartedBook>> GetPage() =>
await eventStore.ReadModels.Materialized.GetInstances<GetStartedBook>(skip: 0, take: 20);
}

View C# snippet source on GitHub

skip and take are plain paging (they default to 0 and 50), so you can window through a large collection without ever loading all of it — Materialized read models shows the paging and observing patterns.

Neither call filters, though: GetInstances returns everything (you’d filter with LINQ in memory), and Materialized only pages. When you need to filter efficiently — “which books are on loan?” — query the sink’s database directly with its native tools. Our sink is MongoDB, so GetStartedBooks is an ordinary collection that your language’s MongoDB driver can query (the Chronicle client does not bring one in; add it to your project):

using MongoDB.Driver;
public class GetStartedBooks(IMongoCollection<GetStartedBook> collection)
{
public IEnumerable<GetStartedBook> OnLoan() => collection.Find(b => b.OnLoan).ToList();
}

View C# snippet source on GitHub

Append a GetStartedBookBorrowed against the same bookId, query again, and OnLoan is true with BorrowedBy set — and a GetStartedBorrowedBook now sits in its collection. Append a GetStartedBookReturned and both flip back: the flag clears, the GetStartedBorrowedBook disappears. You never wrote an UPDATE. The trade-offs between the on-demand and materialized paths are laid out in read model consistency.

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 GetStartedBookReturnedNotifier : IReactor
{
public Task Returned(GetStartedBookReturned @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;
}
}

View C# snippet source on GitHub

No registration, no wiring — drop the class in and every GetStartedBookReturned flows to it. Reactors must be idempotent, because the same event may be delivered more than once (during a replay or a recovery). Delivery identity explains what Chronicle does and does not guarantee there. 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.

Chronicle installs its unit of work and causation middleware automatically through startup filters. These wrap your application’s middleware, including authentication. Starting the request unit of work does not resolve its event store or namespace: it binds on the first operation that needs the store. Resolving the scoped IEventStore, IEventLog, or read models also resolves the namespace at that point. A successful resolution is reused for the rest of the request, including the unit of work’s commit.

For a claim-based namespace resolver, run authentication before any middleware or endpoint that uses Chronicle. Read the tenant claim from IHttpContextAccessor.HttpContext.User in the resolver. For HTTP requests, reject missing or invalid tenant claims rather than falling back to a shared namespace. Handle off-request resolution separately: client startup and background work have no HTTP principal. Unauthenticated requests rejected by authorization, and requests that never use Chronicle, do not need a namespace merely to start or complete their request unit of work. Using Chronicle inside an authentication handler still requires a namespace to be available at that point.

If you need explicit control over ordering, set UseAutomaticMiddleware = false in the options passed to builder.AddCratisChronicle(...). Then configure the pipeline in this order:

  1. app.UseRouting()
  2. app.UseAuthentication()
  3. app.UseAuthorization()
  4. app.UseCratisChronicle()
  5. Map your endpoints.

In this mode, UseCratisChronicle() installs both the unit of work and causation middleware at that position, in addition to its normal client startup configuration. Leave automatic middleware enabled for existing applications that do not need explicit placement. Calling UseCratisChronicle() more than once on the same application builder does not install duplicate middleware. Automatic and explicit placement retain the same commit-on-success, rollback-on-failure, and request completion-feature behavior.

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:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
public static class AspNetCoreBookEndpoint
{
public static void MapEndpoints(WebApplication app)
{
app.MapPost("/api/books/{bookId}/borrow", async (
[FromServices] IEventLog eventLog,
[FromRoute] Guid bookId,
[FromQuery] string memberName) =>
await eventLog.Append(bookId, new GetStartedBookBorrowed(memberName)));
}
}

View C# snippet source on GitHub

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 projections pick it up from there — GetStartedBook flips to on loan and a GetStartedBorrowedBook instance appears — along with any reactors. The append returns once the event is in the log; the materialized read models catch up a moment later.

Chronicle creates its discovered artifacts — reactors, reducers, projections — through the container. If an artifact is registered as a service, Chronicle uses that registration; otherwise it constructs the artifact itself and resolves its constructor parameters from the container. Registering the artifact itself is therefore optional — do it when you want to control its lifetime:

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
public static class AspNetCoreExplicitRegistration
{
public static void ConfigureServices(WebApplicationBuilder builder)
{
builder.Services.AddTransient<GetStartedBookReturnedNotifier>();
}
}

View C# snippet source on GitHub

What you must register is everything an artifact depends on, such as the notification service a real waitlist reactor would take. As the solution grows, Cratis Fundamentals can register those services by convention:

using Cratis.DependencyInjection;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
public static class AspNetCoreConventionRegistration
{
public static void ConfigureServices(WebApplicationBuilder builder)
{
builder.Services
.AddBindingsByConvention()
.AddSelfBindings();
}
}

View C# snippet source on GitHub

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

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

When you read Chronicle’s read models with the MongoDB driver, the driver has to expect the documents Chronicle writes. Three things matter:

  • Chronicle adds metadata fields of its own, such as __subject, so the driver must ignore elements your type does not declare.
  • Identifiers such as a Guid key are stored as standard UUIDs.
  • Element names match your property names exactly as declared, and each collection is named after its read model, pluralized. Chronicle only writes camel-case names if you opt into its camel case naming policy; match the driver to whichever policy you chose, or queries match nothing without reporting an error.
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Conventions;
using MongoDB.Bson.Serialization.Serializers;
public static class GetStartedMongoDbDefaults
{
public static void Configure()
{
BsonSerializer
.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
var pack = new ConventionPack
{
// Chronicle adds its own metadata fields to every document, such as __subject.
new IgnoreExtraElementsConvention(true)
// Element names match your C# property names by default. Add a
// CamelCaseElementNameConvention here only if you configured Chronicle
// with the camel case naming policy.
};
ConventionRegistry.Register("conventions", pack, t => true);
}
}

View C# snippet source on GitHub

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

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
public static class AspNetCoreMongoRegistration
{
public static void ConfigureServices(WebApplicationBuilder builder)
{
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<GetStartedBook>("GetStartedBooks"));
}
}

View C# snippet source on GitHub

Now the GetStartedBooks query from the querying section above resolves its collection straight from the container.

You added Chronicle to an ASP.NET Core app with two lines in Program.cs — AddCratisChronicle 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 top — Arc 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.