Skip to content

Add Chronicle to a worker service

A worker service is the natural home for the reacting side of an event-sourced system: no web front end, just a long-running host that processes events, runs scheduled jobs, or keeps derived data up to date. Setup is mostly a matter of letting the generic host’s DI container do the wiring — register Chronicle once, then inject what you need wherever you need it.

We’ll build the same small library domain as the other host guides. If you’re building a web API instead, the ASP.NET Core guide covers that host; for the bare-bones, no-container version, see the console guide.

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.

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

Terminal window
dotnet new worker

Add a reference to the Chronicle client package:

Terminal window
dotnet add package Cratis.Chronicle

The generic host builds your app through IHostApplicationBuilder, which already has a dependency-injection container. One call hooks Chronicle into it and names the event store to use:

var builder = Host.CreateApplicationBuilder(args);
builder.AddCratisChronicle(options => options.EventStore = "Quickstart");
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
await host.RunAsync();

Like the ASP.NET Core host, AddCratisChronicle registers Chronicle’s services — IEventStore, IEventLog, IReactors, IReducers, IProjections — and automatically discovers and registers your artifacts (reactors, reducers, projections) from the loaded assemblies. It reads its connection settings from the Cratis:Chronicle section of appsettings.json:

AddCratisChronicle

Host.CreateApplicationBuilder

DI container

(auto-discovers artifacts)

IEventStore · IEventLog ·

IReactors · IReducers · IProjections

your BackgroundService

appsettings.json
{
"Cratis": {
"Chronicle": {
"ConnectionString": "chronicle://localhost:35000",
"EventStore": "Quickstart"
}
}
}

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 worker you append from your BackgroundService rather than inline. Inject IEventStore (or IEventLog directly) and append inside ExecuteAsync:

public class Worker(IEventStore eventStore) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await eventStore.Connection.Connect();
var bookId = Guid.NewGuid();
await eventStore.EventLog.Append(bookId, new BookAdded("The Pragmatic Programmer", "978-0135957059"));
// Keep running so reactors and projections keep processing.
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}

The projection and the BookReturnedNotifier reactor pick those events up from the kernel — the worker stays alive to keep processing them.

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 collection so a type can take an IMongoCollection<Book> dependency:

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"));

Chronicle creates its discovered artifacts through the container, so they need to be registered as services. For a handful, register them explicitly; as the solution grows, let Cratis Fundamentals 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.

  • Multi-tenant namespaces — provide a custom IEventStoreNamespaceResolver to route operations per tenant. See Namespace resolution.
  • Structural dependencies — supply custom identity providers, correlation-id accessors, or namespace resolvers through the configure callback on AddCratisChronicle. See Structural dependencies.

You added Chronicle to a worker service with a single AddCratisChronicle call, pointed it at an event store through appsettings.json, and appended events from a BackgroundService — the generic host’s DI container discovered your reactors, reducers, and projections and handed you the services to use them. The same library domain runs here unchanged from the other hosts.

  • Build the domain step by step — the tutorial walks the library model one concept at a time.
  • Reacting to events — a worker’s main job; see Reactors for the patterns.
  • A different host — the same artifacts run unchanged behind a web API (ASP.NET Core) or with no container at all (console).