Skip to content

Add Chronicle to a console app

A console app is the smallest place to run Chronicle: no web host, no dependency-injection container, nothing between you and the client. That’s exactly why it’s the clearest place to start — every moving part is something you write explicitly, so nothing is hidden by convention. (The Worker Service and ASP.NET Core guides let the host’s DI container wire the same pieces up for you; start here if you want to see what that wiring actually does.)

We’ll build a small, familiar domain — a library — and by the end you’ll have appended events and projected them into read models you can query in MongoDB.

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 Console quickstart sample on GitHub.

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

Terminal window
dotnet new console

Add a reference to the Chronicle client package:

Terminal window
dotnet add package Cratis.Chronicle

Everything in Chronicle is reached through a ChronicleClient. From a client you ask for the event store you want to work with — here, one named Quickstart. Because there’s no DI container to do it for you, you create the client yourself:

using Cratis.Chronicle;
using Cratis.Chronicle.Connections;
public static class GetStartedConsoleClient
{
public static async Task Run()
{
// ChronicleConnectionString.Development points at the local dev kernel on chronicle://localhost:35000
using var client = new ChronicleClient(ChronicleConnectionString.Development);
var eventStore = await client.GetEventStore("Quickstart");
Console.WriteLine($"Connected to event store: {eventStore.Name}");
// Use eventStore for the lifetime of your program — appending, querying, and so on.
}
}

ChronicleConnectionString.Development is the built-in connection string for the local development kernel — the same one new ChronicleClient() uses when you call it with no arguments. Spelling it out keeps the console version explicit; point it elsewhere with ChronicleConnectionString.Default (no credentials) or your own new ChronicleConnectionString("chronicle://…").

That single eventStore is your handle to everything that follows:

Your console code

ChronicleClient

event store 'Quickstart'

Chronicle kernel (Docker)

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;

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;
}
}

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

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 Book, 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);

The moment a BookBorrowed lands, a BorrowedBook instance appears — keyed by the book’s id, its MemberName mapped by the same naming convention. When the matching BookReturned arrives, [RemovedWith<BookReturned>] 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);
}
}

These results are strongly consistent: Chronicle replays the read model’s events on demand, so what comes back reflects every event appended up to that instant — including the one you appended a moment ago. That replay is also the cost: every call processes all the events feeding the read model, which is perfect for a quickstart but not necessarily what you’d put on a hot path in production. You can cap the work by passing an event count — GetInstances<Book>(eventCount: 1_000) — but a capped replay can stop before the newest events and hand you incomplete results. Getting a collection of instances covers the details. Kotlin and Java currently only support fetching a single instance by key (getInstanceByKey) — fetching every instance of a read model isn’t available yet.

By default Chronicle also materializes every projection into the sink storage configured for the event store — MongoDB unless you change it — under a database named after the event store and a collection named after the read model. Materialization happens in the background as events arrive, so it’s eventually consistent: a freshly appended event may take a moment to show up. In exchange, a query costs nothing but a database fetch. The Materialized API reads those 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);
}

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 the Book read model is an ordinary collection and the driver does what it does best:

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

Append a BookBorrowed against the same bookId, query again, and OnLoan is true with BorrowedBy set — and a BorrowedBook now sits in its collection. Append a BookReturned and both flip back: the flag clears, the BorrowedBook 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;
}
}

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.

The Books query above reads documents Chronicle wrote. For your IMongoCollection<Book> to deserialize them, the MongoDB driver needs to match how Chronicle stores documents — 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:

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
{
// 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

With the conventions registered, the Books query reads the projection’s documents exactly as written.

You wired Chronicle into a bare console app by hand: created a ChronicleClient, opened the Quickstart event store, appended events for a small library domain, projected them into the Book and BorrowedBook read models with model-bound attributes, queried them both on demand and from the materialized sink, and reacted to one with a reactor. Because there was no DI container, every connection was explicit and in plain sight.

  • Build the same domain step by step — the tutorial walks the library model one concept at a time, explaining each as you go.
  • Let a host wire it up — move the same code into a Worker Service or an ASP.NET Core app and let its DI container register the artifacts for you.
  • Understand the pieces — the Concepts section defines events, projections, reducers, and reactors in depth.