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
@EventType(id = "BookBorrowed")data class BookBorrowed(val memberName: String)
@EventType(id = "BookReturned")class BookReturnedimport io.cratis.chronicle.events.EventType;
@EventType(id = "BookBorrowed")record BookBorrowed(String memberName) {}
@EventType(id = "BookReturned")record 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();}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);Kotlin does not support this workflow yet.Java does not support this workflow yet.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();}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 →