2. Building a read model
We can record that a book arrived, but the librarian can’t see the catalog yet — the books live only as history in the log. In this chapter we’ll fix that: we’ll build a Books read model that always reflects the current state of every book, and — here’s the part that surprises people coming from CRUD — we’ll do it without writing a single line that updates anything.
In event-modeling terms that’s the view pattern — events fold into a read model the UI can query. It’s the slice of the model we build in this chapter:
First, a couple more facts
Section titled “First, a couple more facts”A book doesn’t just arrive; it gets borrowed and brought back. Those are facts too, so they’re events:
using Cratis.Chronicle.Events;
[EventType]public record BookBorrowed(string MemberName);
[EventType]public record BookReturned;import io.cratis.chronicle.events.EventType
@EventTypedata class BookBorrowed(val memberName: String)
@EventTypeclass BookReturnedimport io.cratis.chronicle.events.EventType;
@EventTyperecord BookBorrowed(String memberName) {}
@EventTyperecord BookReturned() {}defmodule MyApp.Events.BookBorrowed do use Chronicle.Events.EventType, id: "book-borrowed"
defstruct [:member_name]end
defmodule MyApp.Events.BookReturned do use Chronicle.Events.EventType, id: "book-returned"
defstruct []endimport { eventType } from '@cratis/chronicle';
@eventType()class BookBorrowed { constructor(readonly memberName: string) {}}
@eventType()class BookReturned {}Notice BookReturned has no data at all — and that’s fine. The fact that it happened, on a particular book’s stream, at a particular time, is the whole story. Not every event needs a payload.
Declare what you want to read
Section titled “Declare what you want to read”Here’s the shift. In a database you’d write code to keep a Books table in sync — insert on add, update a flag on borrow, update it back on return. In Chronicle you instead declare the shape you want and tell it which events feed it. Chronicle does the keeping-in-sync for you. That declaration is a projection:
using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
[FromEvent<BookAdded>]public record Book( [Key] BookId Id,
string Title, string Isbn,
[SetValue<BookAdded>(false)] [SetValue<BookBorrowed>(true)] [SetValue<BookReturned>(false)] bool OnLoan,
[SetFrom<BookBorrowed>(nameof(BookBorrowed.MemberName))] string? BorrowedBy);Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.ReadModels.Book do use Chronicle.ReadModels.ReadModel
defstruct id: nil, title: nil, isbn: nil, on_loan: false, borrowed_by: nil
from MyApp.Events.BookAdded, set: [id: :event_source_id, title: :title, isbn: :isbn, on_loan: false]
from MyApp.Events.BookBorrowed, set: [on_loan: true, borrowed_by: :member_name]
from MyApp.Events.BookReturned, set: [on_loan: false]endimport { fromEvent, Guid, readModel, setFrom, setValue } from '@cratis/chronicle';
@readModel()@fromEvent(BookAdded)class Book { id: Guid = Guid.empty;
@setFrom(BookAdded, 'title') title = '';
@setFrom(BookAdded, 'isbn') isbn = '';
@setValue(BookAdded, false) @setValue(BookBorrowed, true) @setValue(BookReturned, false) onLoan = false;
@setFrom(BookBorrowed, 'memberName') borrowedBy: string | null = null;}Read the attributes as a sentence: a book is made from BookAdded — its Title and Isbn come straight off the event; OnLoan is false when the book is added, true when it’s borrowed, and false again when it’s returned; BorrowedBy is set to whoever borrowed it. You’re declaring how each fact maps onto the view — not writing imperative updates, not worrying about ordering. Chronicle replays the events in order and applies your mapping.
Kotlin and Java’s model-bound projections don’t currently have an equivalent to [SetValue<T>] — setting a literal value per event type — so this particular projection isn’t available in those two clients yet.
Query it
Section titled “Query it”By default Chronicle materializes the projection into its configured sink storage — MongoDB unless you change it — so the Book read model is just a collection you query, exactly what you’re used to:
using MongoDB.Driver;
public class Books(IMongoCollection<Book> collection){ public IEnumerable<Book> OnLoan() => collection.Find(b => b.OnLoan).ToList();}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class TutorialBook(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 Books(private val store: IEventStore) { suspend fun onLoan(): List<TutorialBook> = store.readModels.getInstances(TutorialBook::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 TutorialBook(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 Books { private final IEventStore store;
Books(IEventStore store) { this.store = store; }
List<TutorialBook> onLoan() { return ReadModelsJavaBridge.getInstances(store.getReadModels(), TutorialBook.class).stream() .filter(TutorialBook::onLoan) .collect(Collectors.toList()); }}defmodule MyApp.ReadModels.TutorialQueryBook do defstruct [:title, :on_loan]end
defmodule MyApp.TutorialQueryBooksService do alias MyApp.ReadModels.TutorialQueryBook
def on_loan do {:ok, books} = Chronicle.all(TutorialQueryBook) Enum.filter(books, & &1.on_loan) endendimport { IEventStore } from '@cratis/chronicle';
class Books { constructor(private readonly store: IEventStore) {}
async onLoan(): Promise<Book[]> { const books = await this.store.readModels.getInstances(Book); return books.filter(book => book.onLoan); }}Now exercise it. Append a BookBorrowed for your book and query again — OnLoan is true, and BorrowedBy has the member’s name. Append a BookReturned and it flips back. You never wrote an UPDATE. The projection did it, by re-deriving the book from its events.
A view that removes itself
Section titled “A view that removes itself”The catalog answers “what books do we have?” — but the librarian’s most common question at the desk is sharper: what’s out on loan right now? You could filter Book on OnLoan, and we just did. But there’s a more direct way to model it: a read model whose instances exist only while the book is out. A BorrowedBook appears when a book is borrowed, and disappears when it comes back:
using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
[FromEvent<BookBorrowed>][RemovedWith<BookReturned>]public record BorrowedBook( [Key] BookId Id,
string MemberName);import io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.projections.RemovedWithimport io.cratis.chronicle.readModels.ReadModel
@ReadModel@FromEvent(BookBorrowed::class)@RemovedWith(BookReturned::class)data class BorrowedBook( 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 = BookBorrowed.class)@RemovedWith(eventType = BookReturned.class)class BorrowedBook { private String id = ""; private String memberName = "";
public BorrowedBook() {}
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.BorrowedBook do use Chronicle.ReadModels.ReadModel
defstruct id: nil, member_name: nil
from MyApp.Events.BookBorrowed, set: [id: :event_source_id, member_name: :member_name]
removed_with MyApp.Events.BookReturned, []endimport { fromEvent, Guid, readModel, removedWith, setFrom } from '@cratis/chronicle';
@readModel()@fromEvent(BookBorrowed)@removedWith(BookReturned)class BorrowedBook { id: Guid = Guid.empty;
@setFrom(BookBorrowed, 'memberName') memberName = '';}Kotlin and Java don’t currently have an equivalent to [RemovedWith<T>] either, so this one is C#/Elixir/TypeScript only for now.
And its own query, just as plain as the last one:
using MongoDB.Driver;
public class BorrowedBooks(IMongoCollection<BorrowedBook> collection){ public IEnumerable<BorrowedBook> All() => collection.Find(_ => true).ToList();}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class TutorialBorrowedBook(val title: String = "", val memberName: String = "")
/** * Kotlin has no raw MongoCollection to query directly - reads go through the read model service * instead. */class BorrowedBooks(private val store: IEventStore) { suspend fun all(): List<TutorialBorrowedBook> = store.readModels.getInstances(TutorialBorrowedBook::class)}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.ReadModelsJavaBridge;import io.cratis.chronicle.readModels.ReadModel;
import java.util.List;
@ReadModelrecord TutorialBorrowedBook(String title, String memberName) {}
// Java has no raw MongoCollection to query directly - reads go through the read model service// instead.class BorrowedBooks { private final IEventStore store;
BorrowedBooks(IEventStore store) { this.store = store; }
List<TutorialBorrowedBook> all() { return ReadModelsJavaBridge.getInstances(store.getReadModels(), TutorialBorrowedBook.class); }}defmodule MyApp.ReadModels.TutorialBorrowedBook do defstruct [:member_name]end
defmodule MyApp.TutorialBorrowedBooksService do alias MyApp.ReadModels.TutorialBorrowedBook
def all do {:ok, borrowed_books} = Chronicle.all(TutorialBorrowedBook) borrowed_books endendimport { IEventStore } from '@cratis/chronicle';
class BorrowedBooks { constructor(private readonly store: IEventStore) {}
all(): Promise<BorrowedBook[]> { return this.store.readModels.getInstances(BorrowedBook); }}Two attributes carry the whole lifecycle. [FromEvent<BookBorrowed>] creates the instance when the borrow happens — MemberName mapped by convention, keyed by the book’s id. [RemovedWith<BookReturned>] is the new move: when a BookReturned arrives on that same book’s stream, Chronicle deletes the instance from the sink. No IsActive flag, no soft-delete column, no cleanup job — the collection is the answer to “what’s out right now”, because instances that no longer apply simply aren’t in it.
Notice what just happened to your modeling instincts, too: instead of bending one Books table to answer every question, you built a second, purpose-shaped view over the same events. Read models are cheap in Chronicle — they’re derived, so you can have as many as you have questions.
What you did
Section titled “What you did”- Added the events that make up a book’s life (
BookBorrowed,BookReturned). - Declared a
Booksread model and how events map onto it — no update code anywhere. - Queried it like ordinary data, and watched it stay correct on its own.
- Built a second view,
BorrowedBook, whose instances are removed when the book comes back — two questions, two read models, one stream of facts.
You can now see the catalog. The last piece is to make the library do something when the world changes — when a book comes back, tell the next person waiting for it. That’s a job for a reactor, and it’s the final chapter. Let’s finish the tour →