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.
Before you start
Section titled “Before you start”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.
Set up the project
Section titled “Set up the project”Create a folder for your project, then a .NET web project inside it:
dotnet new webAdd a reference to the Chronicle ASP.NET Core package (it brings in the base Chronicle package for you):
dotnet add package Cratis.Chronicle.AspNetCoreRegister Chronicle on the host
Section titled “Register Chronicle on the host”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(); }}/** * The `cratis-chronicle-spring-boot-starter` on the classpath is the entire integration - there * is no `AddCratisChronicle`/`UseCratisChronicle` to call. Autoconfiguration connects to the * kernel, discovers every event type, read model, reducer, reactor and constraint in this * application's packages, and registers them before the first request is served. The entry point * itself needs nothing Chronicle-specific - only Spring Boot's own `@SpringBootApplication` class * and `runApplication<...>(*args)`. Point it at an event store with one setting in * application.yml: * * ```yaml * cratis: * chronicle: * event-store: Quickstart * ``` */object AspNetCoreRegistration/** * The {@code cratis-chronicle-spring-boot-starter} on the classpath is the entire integration - * there is no {@code AddCratisChronicle}/{@code UseCratisChronicle} to call. Autoconfiguration * connects to the kernel, discovers every event type, read model, reducer, reactor and * constraint in this application's packages, and registers them before the first request is * served. The entry point itself needs nothing Chronicle-specific - only Spring Boot's own * {@code @SpringBootApplication} class and {@code SpringApplication.run(...)}. Point it at an * event store with one setting in application.yml: * * <pre> * cratis: * chronicle: * event-store: Quickstart * </pre> */class AspNetCoreRegistration {}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.
Define some events
Section titled “Define some events”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;import io.cratis.chronicle.events.EventType
@EventTypedata class GetStartedBookAdded(val title: String, val isbn: String)
@EventTypedata class GetStartedBookBorrowed(val memberName: String)
@EventTypeclass GetStartedBookReturnedimport io.cratis.chronicle.events.EventType;
@EventTyperecord GetStartedBookAdded(String title, String isbn) {}
@EventTyperecord GetStartedBookBorrowed(String memberName) {}
@EventTyperecord GetStartedBookReturned() {}defmodule MyApp.Events.GetStartedBookAdded do use Chronicle.Events.EventType, id: "get-started-book-added"
defstruct [:title, :isbn]end
defmodule MyApp.Events.GetStartedBookBorrowed do use Chronicle.Events.EventType, id: "get-started-book-borrowed"
defstruct [:member_name]end
defmodule MyApp.Events.GetStartedBookReturned do use Chronicle.Events.EventType, id: "get-started-book-returned"
defstruct []endimport { eventType } from '@cratis/chronicle';
@eventType()class GetStartedBookAdded { constructor( readonly title: string, readonly isbn: string ) {}}
@eventType()class GetStartedBookBorrowed { constructor(readonly memberName: string) {}}
@eventType()class 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.
Append them
Section titled “Append them”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; }}import io.cratis.chronicle.IEventStoreimport java.util.UUID
class GetStartedBookService(private val eventStore: IEventStore) { suspend fun addBook(): String { val eventLog = eventStore.eventLog
val bookId = UUID.randomUUID().toString() eventLog.append(bookId, GetStartedBookAdded("The Pragmatic Programmer", "978-0135957059"))
return bookId }}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.BlockingEventStore;import java.util.UUID;
class GetStartedBookService { private final BlockingEventStore eventStore;
GetStartedBookService(IEventStore eventStore) { this.eventStore = new BlockingEventStore(eventStore); }
String addBook() { var bookId = UUID.randomUUID().toString();
eventStore.getEventLog().append( bookId, new GetStartedBookAdded("The Pragmatic Programmer", "978-0135957059"));
return bookId; }}defmodule MyApp.BookService do alias MyApp.Events.GetStartedBookAdded
def add_book do book_id = Base.encode16(:crypto.strong_rand_bytes(8), case: :lower)
:ok = Chronicle.append(book_id, %GetStartedBookAdded{ title: "The Pragmatic Programmer", isbn: "978-0135957059" })
book_id endendimport { IEventStore } from '@cratis/chronicle';
class GetStartedBookService { constructor(private readonly store: IEventStore) {}
async addBook(bookId: string): Promise<void> { const eventLog = this.store.eventLog;
await eventLog.append(bookId, new GetStartedBookAdded('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.

Turn events into a read model
Section titled “Turn events into a read model”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);Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.ReadModels.GetStartedBook do use Chronicle.ReadModels.ReadModel
defstruct id: nil, title: nil, isbn: nil, on_loan: false, borrowed_by: nil
from MyApp.Events.GetStartedBookAdded, set: [id: :event_source_id, title: :title, isbn: :isbn, on_loan: false]
from MyApp.Events.GetStartedBookBorrowed, set: [on_loan: true, borrowed_by: :member_name]
from MyApp.Events.GetStartedBookReturned, set: [on_loan: false]endimport { fromEvent, Guid, readModel, setFrom, setValue } from '@cratis/chronicle';
@readModel()@fromEvent(GetStartedBookAdded)class GetStartedBook { id: Guid = Guid.empty;
@setFrom(GetStartedBookAdded, 'title') title = '';
@setFrom(GetStartedBookAdded, 'isbn') isbn = '';
@setValue(GetStartedBookAdded, false) @setValue(GetStartedBookBorrowed, true) @setValue(GetStartedBookReturned, false) onLoan = false;
@setFrom(GetStartedBookBorrowed, 'memberName') borrowedBy: string | null = null;}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);import io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.projections.RemovedWithimport io.cratis.chronicle.readModels.ReadModel
@ReadModel@FromEvent(GetStartedBookBorrowed::class)@RemovedWith(GetStartedBookReturned::class)data class GetStartedBorrowedBook( val id: String = "", val memberName: String = "")import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.projections.RemovedWith;import io.cratis.chronicle.readModels.ReadModel;
@ReadModel@FromEvent(eventType = GetStartedBookBorrowed.class)@RemovedWith(eventType = GetStartedBookReturned.class)class GetStartedBorrowedBook { private String id = ""; private String memberName = "";
public GetStartedBorrowedBook() {}
public String getId() { return id; } public void setId(String id) { this.id = id; }
public String getMemberName() { return memberName; } public void setMemberName(String memberName) { this.memberName = memberName; }}defmodule MyApp.ReadModels.GetStartedBorrowedBook do use Chronicle.ReadModels.ReadModel
defstruct id: nil, member_name: nil
from MyApp.Events.GetStartedBookBorrowed, set: [id: :event_source_id, member_name: :member_name]
removed_with MyApp.Events.GetStartedBookReturned, []endimport { fromEvent, Guid, readModel, removedWith, setFrom } from '@cratis/chronicle';
@readModel()@fromEvent(GetStartedBookBorrowed)@removedWith(GetStartedBookReturned)class GetStartedBorrowedBook { id: Guid = Guid.empty;
@setFrom(GetStartedBookBorrowed, 'memberName') 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>].
Query the read models
Section titled “Query the read models”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); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.BookQueryService do alias MyApp.ReadModels.GetStartedBook alias MyApp.ReadModels.GetStartedBorrowedBook
def query_books do {:ok, books} = Chronicle.all(GetStartedBook) {:ok, borrowed_books} = Chronicle.all(GetStartedBorrowedBook)
{books, borrowed_books} endendimport { IEventStore } from '@cratis/chronicle';
class GetStartedBookQueryService { constructor(private readonly store: IEventStore) {}
async queryBooks(): Promise<{ books: GetStartedBook[]; borrowedBooks: GetStartedBorrowedBook[] }> { const books = await this.store.readModels.getInstances(GetStartedBook); const borrowedBooks = await this.store.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);}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.BookPagingService do alias MyApp.ReadModels.GetStartedBook
def get_page do {:ok, result} = Chronicle.ReadModels.query(GetStartedBook, page: 1, page_size: 20) result.instances endendimport { IEventStore } from '@cratis/chronicle';
class GetStartedBookPagingService { constructor(private readonly store: IEventStore) {}
getPage(): Promise<GetStartedBook[]> { return this.store.readModels.materialized.getInstances(GetStartedBook, 0, 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();}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class GetStartedBook(val title: String = "", val onLoan: Boolean = false)
/** * Kotlin has no raw MongoCollection to query directly - reads go through the read model service * instead, which returns already-deserialized instances to filter in memory. */class GetStartedBooks(private val store: IEventStore) { suspend fun onLoan(): List<GetStartedBook> = store.readModels.getInstances(GetStartedBook::class).filter { it.onLoan }}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.ReadModelsJavaBridge;import io.cratis.chronicle.readModels.ReadModel;
import java.util.List;import java.util.stream.Collectors;
@ReadModelrecord GetStartedBook(String title, boolean onLoan) {}
// Java has no raw MongoCollection to query directly - reads go through the read model service// instead, which returns already-deserialized instances to filter in memory.class GetStartedBooks { private final IEventStore store;
GetStartedBooks(IEventStore store) { this.store = store; }
List<GetStartedBook> onLoan() { return ReadModelsJavaBridge.getInstances(store.getReadModels(), GetStartedBook.class).stream() .filter(GetStartedBook::onLoan) .collect(Collectors.toList()); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.
React when something happens
Section titled “React when something happens”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; }}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.observation.Reactor
@Reactorclass GetStartedBookReturnedNotifier { fun returned(event: GetStartedBookReturned, context: EventContext) { // context.eventSourceId is the bookId this happened to println("Book ${context.eventSourceId} was returned — notify the next member in line.") }}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.observation.Reactor;
@Reactorclass GetStartedBookReturnedNotifier { void returned(GetStartedBookReturned event, EventContext context) { // context.getEventSourceId() is the bookId this happened to System.out.println("Book " + context.getEventSourceId() + " was returned — notify the next member in line."); }}defmodule MyApp.Reactors.GetStartedBookReturnedNotifier do use Chronicle.Reactors.Reactor
alias MyApp.Events.GetStartedBookReturned
@handles GetStartedBookReturned
@impl true def handle(%GetStartedBookReturned{}, %{event_source_id: book_id}) do # book_id is the id this happened to IO.puts("Book #{book_id} was returned — notify the next member in line.") :ok endendimport { EventContext, reactor } from '@cratis/chronicle';
@reactor()class GetStartedBookReturnedNotifier { // Method name must be the exact camelCase of the event's class name - // Chronicle discovers handlers by name, not by parameter type. async getStartedBookReturned(event: GetStartedBookReturned, context: EventContext): Promise<void> { // context.eventSourceId is the bookId this happened to console.log(`Book ${context.eventSourceId} was returned — notify the next member in line.`); }}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.
Append from an endpoint
Section titled “Append from an endpoint”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))); }}import io.cratis.chronicle.IEventStore
/** * Kotlin has no ASP.NET Core minimal-API route builder to show here - the handler is a plain * suspend function taking the values a web framework would already have extracted from the * request (a path variable and a query parameter), and appends the event directly. Spring MVC * handlers are synchronous, so bridge with runBlocking there; on WebFlux, or anywhere that * already supports suspend functions, call this directly instead. */class AspNetCoreBookEndpoint(private val eventStore: IEventStore) { suspend fun borrow(bookId: String, memberName: String) = eventStore.eventLog.append(bookId, GetStartedBookBorrowed(memberName))}import io.cratis.chronicle.java.BlockingEventStore;
// Java has no ASP.NET Core minimal-API route builder to show here - the handler is a plain// method taking the values a web framework would already have extracted from the request (a// path variable and a query parameter), and appends the event directly through the blocking// event store facade a Spring MVC handler can call without any coroutine bridging.class AspNetCoreBookEndpoint { private final BlockingEventStore eventStore;
AspNetCoreBookEndpoint(BlockingEventStore eventStore) { this.eventStore = eventStore; }
void borrow(String bookId, String memberName) { eventStore.getEventLog().append(bookId, new GetStartedBookBorrowed(memberName)); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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 — Book flips to on loan and a BorrowedBook instance appears — along with any reactors.
Register your artifacts
Section titled “Register your artifacts”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:
using Microsoft.AspNetCore.Builder;using Microsoft.Extensions.DependencyInjection;
public static class AspNetCoreExplicitRegistration{ public static void ConfigureServices(WebApplicationBuilder builder) { builder.Services.AddTransient<GetStartedBookReturnedNotifier>(); }}import io.cratis.chronicle.IEventStoreimport kotlinx.coroutines.runBlocking
/** * Autodiscovery already finds every `@Reactor` in a scanned package - this is only needed when a * reactor is constructed with something Spring cannot inject on its own, or when * `cratis.chronicle.auto-discover-and-register` is turned off and every artifact is registered * by hand. In a Spring Boot application this factory function is exposed as an `@Bean` inside an * `@Configuration` class, so the constructed instance is also available for injection elsewhere. */fun registerBookReturnedNotifier(eventStore: IEventStore): GetStartedBookReturnedNotifier { val notifier = GetStartedBookReturnedNotifier() runBlocking { eventStore.reactors.register(notifier) } return notifier}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.ReactorsServiceJavaBridge;
// Autodiscovery already finds every @Reactor in a scanned package - this is only needed when a// reactor is constructed with something Spring cannot inject on its own, or when// cratis.chronicle.auto-discover-and-register is turned off and every artifact is registered by// hand. In a Spring Boot application this factory method is exposed as an @Bean inside an// @Configuration class, so the constructed instance is also available for injection elsewhere.class AspNetCoreExplicitRegistration { GetStartedBookReturnedNotifier bookReturnedNotifier(IEventStore eventStore) { GetStartedBookReturnedNotifier notifier = new GetStartedBookReturnedNotifier(); ReactorsServiceJavaBridge.register(eventStore.getReactors(), notifier); return notifier; }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.As the solution grows this gets tedious, so Cratis Fundamentals can do it 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(); }}/** * Spring's own convention is `@ComponentScan`: every class carrying a recognized stereotype in * the scanned packages becomes a registered bean automatically - the same mechanism that finds * Chronicle artifacts. `@SpringBootApplication` already implies scanning its own package and * below, so this is only needed to widen the scan to another package explicitly, by adding * `@ComponentScan("io.cratis.chronicle.samples.getstarted")` to an `@Configuration` class. */object AspNetCoreConventionRegistration// Spring's own convention is @ComponentScan: every class carrying a recognized stereotype in the// scanned packages becomes a registered bean automatically - the same mechanism that finds// Chronicle artifacts. @SpringBootApplication already implies scanning its own package and// below, so this is only needed to widen the scan to another package explicitly, by adding// @ComponentScan("io.cratis.chronicle.samples.getstarted") to an @Configuration class.class AspNetCoreConventionRegistration {}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.
Configure the MongoDB client
Section titled “Configure the MongoDB client”The Books query reads documents Chronicle wrote, so the MongoDB driver needs to match how Chronicle stores them — register these conventions once at startup:
MongoDB
Section titled “MongoDB”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); }}/** * Kotlin has nothing to configure here. Chronicle stores event source ids as standard UUIDs and * read model fields as camelCase names - which already match Kotlin property names - and reads * go through `IEventStore.readModels` rather than a raw MongoDB driver, so there is no * `MongoClientSettings`, GUID representation, or "ignore unknown elements" convention to * register. Unknown document fields are simply not deserialized into properties the read model * class does not declare. */object GetStartedMongoDbDefaults// Java has nothing to configure here. Chronicle stores event source ids as standard UUIDs and// read model fields as camelCase names - which already match Java property names - and reads go// through IEventStore.getReadModels() rather than a raw MongoDB driver, so there is no// MongoClientSettings, GUID representation, or "ignore unknown elements" convention to register.// Unknown document fields are simply not deserialized into properties the read model class does// not declare.class GetStartedMongoDbDefaults {}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Then register the database and the collections you want to inject, so a type can take an IMongoCollection<Book> 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>("Books")); }}/** * Kotlin needs no separate MongoDB registration step here. Queries go through * `IEventStore.readModels`, which the connected client already exposes - there is no raw * `MongoCollection` to construct or register as a bean, in Spring Boot or anywhere else. See * `get-started/common/mongo-query` for the equivalent query. */object AspNetCoreMongoRegistration// Java needs no separate MongoDB registration step here. Queries go through// IEventStore.getReadModels(), which the connected client already exposes - there is no raw// MongoCollection to construct or register as a bean, in Spring Boot or anywhere else. See// get-started/common/mongo-query for the equivalent query.class AspNetCoreMongoRegistration {}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Now the Books 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.
Where to go next
Section titled “Where to go next”- 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.