Materialized Read Models
When a projection or reducer processes an event, Chronicle persists the resulting read model instance in a sink — a database-backed store that holds the materialized state. The Materialized API gives you direct, paginated access to that stored state without replaying the event log.
Client APIs express paging differently: Elixir’s Chronicle.ReadModels.query/2 uses page/page-size;
TypeScript’s materialized API uses skip/take and an AsyncIterable for page observations; Kotlin uses
skip/take and Flow, and Java exposes blocking reads and callback or publisher observations through
ReadModelsJavaBridge. Use each example’s client tab for its API shape. The named paging concepts
and ASP.NET endpoint below illustrate .NET host code.
What Materialized Access Covers
Section titled “What Materialized Access Covers”The Materialized API is intentionally narrow. It answers one question well: give me a page of instances I already know are stored in a sink. This keeps it:
- Database-agnostic — the same call works regardless of whether the sink is MongoDB, SQL, or any future backend
- Simple to use — two methods, optional skip/take, and sensible defaults
- Safe for large datasets — only the requested page is loaded, never the full collection
What it does not cover:
- Filtering by field value
- Sorting by any property
- Aggregation or count queries
- Full-text or range searches
- Complex joins or projections across collections
For those needs, inject the sink’s native client directly. If your sink is MongoDB, inject IMongoCollection<TReadModel>. If it is SQL, inject your DbContext. Those tools are purpose-built for complex queries and Chronicle does not try to replace them.
Accessing the API
Section titled “Accessing the API”IMaterializedReadModels is exposed through IReadModels.Materialized:
using Cratis.Chronicle;
public record MaterializedPaginationOrder(string CustomerName, decimal Total);
public class MaterializedPaginationAccessingApi(IEventStore eventStore){ public async Task<IEnumerable<MaterializedPaginationOrder>> GetOrders() { // Inject IEventStore, then reach through to the Materialized API var instances = await eventStore.ReadModels.Materialized.GetInstances<MaterializedPaginationOrder>(); return instances; }}import io.cratis.chronicle.IEventStore
data class MaterializedPaginationOrder(val customerName: String = "", val total: Double = 0.0)
class MaterializedPaginationAccessingApi(private val eventStore: IEventStore) { // Reach through IEventStore, then the Materialized API suspend fun getOrders(): List<MaterializedPaginationOrder> = eventStore.readModels.materialized.getInstances(MaterializedPaginationOrder::class)}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.ReadModelsJavaBridge;
import java.util.List;
record MaterializedPaginationOrder(String customerName, double total) { MaterializedPaginationOrder() { this("", 0.0); }}
class MaterializedPaginationAccessingApi { private final IEventStore eventStore;
MaterializedPaginationAccessingApi(IEventStore eventStore) { this.eventStore = eventStore; }
// Reach through IEventStore, then the Java bridge for the Materialized API List<MaterializedPaginationOrder> getOrders() { return ReadModelsJavaBridge.getMaterializedInstances(eventStore.getReadModels(), MaterializedPaginationOrder.class, 0, 50); }}defmodule MyApp.ReadModels.MaterializedPaginationOrder do use Chronicle.ReadModels.ReadModel
defstruct customer_name: "", total: 0.0end
defmodule MyApp.MaterializedPaginationAccessingApi do alias MyApp.ReadModels.MaterializedPaginationOrder
def get_orders do # Query the materialized read-model container directly, rather than replaying events Chronicle.ReadModels.query(MaterializedPaginationOrder) endendimport { IEventStore } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
class MaterializedPaginationOrder { @field(String) readonly customerName: string; @field(Number) readonly total: number;
constructor(customerName: string, total: number) { this.customerName = customerName; this.total = total; }}
class MaterializedPaginationAccessingApi { constructor(private readonly store: IEventStore) {}
// Inject IEventStore, then reach through to the materialized API async getOrders(): Promise<MaterializedPaginationOrder[]> { return this.store.readModels.materialized.getInstances(MaterializedPaginationOrder); }}Getting Instances
Section titled “Getting Instances”Basic Usage
Section titled “Basic Usage”Retrieve the first page of stored instances using the defaults (skip: 0, take: 50):
using Cratis.Chronicle;
public class MaterializedPaginationBasicUsage(IEventStore eventStore){ public Task<IEnumerable<MaterializedPaginationOrder>> GetOrders() => eventStore.ReadModels.Materialized.GetInstances<MaterializedPaginationOrder>();}import io.cratis.chronicle.IEventStore
class MaterializedPaginationBasicUsage(private val eventStore: IEventStore) { suspend fun getOrders(): List<MaterializedPaginationOrder> = eventStore.readModels.materialized.getInstances(MaterializedPaginationOrder::class)}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.ReadModelsJavaBridge;
import java.util.List;
class MaterializedPaginationBasicUsage { private final IEventStore eventStore;
MaterializedPaginationBasicUsage(IEventStore eventStore) { this.eventStore = eventStore; }
List<MaterializedPaginationOrder> getOrders() { return ReadModelsJavaBridge.getMaterializedInstances(eventStore.getReadModels(), MaterializedPaginationOrder.class, 0, 50); }}defmodule MyApp.MaterializedPaginationBasicUsage do alias MyApp.ReadModels.MaterializedPaginationOrder
def get_orders do Chronicle.ReadModels.query(MaterializedPaginationOrder) endendimport { IEventStore } from '@cratis/chronicle';
class MaterializedPaginationBasicUsage { constructor(private readonly store: IEventStore) {}
async getOrders(): Promise<MaterializedPaginationOrder[]> { return this.store.readModels.materialized.getInstances(MaterializedPaginationOrder); }}Pagination
Section titled “Pagination”Both skip and take are optional with sensible defaults. Use them for page-based or offset-based navigation:
using System.Linq;using Cratis.Chronicle;
public class MaterializedPaginationPagination(IEventStore eventStore){ public async Task GetPages() { // First page of 20 var page1 = await eventStore.ReadModels.Materialized.GetInstances<MaterializedPaginationOrder>(take: 20); Console.WriteLine($"Page 1: {page1.Count()} orders");
// Second page of 20 var page2 = await eventStore.ReadModels.Materialized.GetInstances<MaterializedPaginationOrder>(skip: 20, take: 20); Console.WriteLine($"Page 2: {page2.Count()} orders");
// Third page of 20 var page3 = await eventStore.ReadModels.Materialized.GetInstances<MaterializedPaginationOrder>(skip: 40, take: 20); Console.WriteLine($"Page 3: {page3.Count()} orders"); }}import io.cratis.chronicle.IEventStore
class MaterializedPaginationPagination(private val eventStore: IEventStore) { suspend fun getPages() { // First page of 20 val page1 = eventStore.readModels.materialized.getInstances(MaterializedPaginationOrder::class, take = 20) println("Page 1: ${page1.size} orders")
// Second page of 20 val page2 = eventStore.readModels.materialized.getInstances(MaterializedPaginationOrder::class, skip = 20, take = 20) println("Page 2: ${page2.size} orders")
// Third page of 20 val page3 = eventStore.readModels.materialized.getInstances(MaterializedPaginationOrder::class, skip = 40, take = 20) println("Page 3: ${page3.size} orders") }}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.ReadModelsJavaBridge;
class MaterializedPaginationPagination { private final IEventStore eventStore;
MaterializedPaginationPagination(IEventStore eventStore) { this.eventStore = eventStore; }
void getPages() { // First page of 20 var page1 = ReadModelsJavaBridge.getMaterializedInstances(eventStore.getReadModels(), MaterializedPaginationOrder.class, 0, 20); System.out.println("Page 1: " + page1.size() + " orders");
// Second page of 20 var page2 = ReadModelsJavaBridge.getMaterializedInstances(eventStore.getReadModels(), MaterializedPaginationOrder.class, 20, 20); System.out.println("Page 2: " + page2.size() + " orders");
// Third page of 20 var page3 = ReadModelsJavaBridge.getMaterializedInstances(eventStore.getReadModels(), MaterializedPaginationOrder.class, 40, 20); System.out.println("Page 3: " + page3.size() + " orders"); }}defmodule MyApp.MaterializedPaginationPagination do alias MyApp.ReadModels.MaterializedPaginationOrder
def get_pages do # First page of 20. Pages count from 0. {:ok, page1} = Chronicle.ReadModels.query(MaterializedPaginationOrder, page: 0, page_size: 20) IO.puts("Page 1: #{length(page1.instances)} orders")
# Second page of 20 {:ok, page2} = Chronicle.ReadModels.query(MaterializedPaginationOrder, page: 1, page_size: 20) IO.puts("Page 2: #{length(page2.instances)} orders")
# Third page of 20 {:ok, page3} = Chronicle.ReadModels.query(MaterializedPaginationOrder, page: 2, page_size: 20) IO.puts("Page 3: #{length(page3.instances)} orders") endendimport { IEventStore } from '@cratis/chronicle';
class MaterializedPaginationPagination { constructor(private readonly store: IEventStore) {}
async getPages(): Promise<void> { // First page of 20 const page1 = await this.store.readModels.materialized.getInstances(MaterializedPaginationOrder, 0, 20); console.log(`Page 1: ${page1.length} orders`);
// Second page of 20 const page2 = await this.store.readModels.materialized.getInstances(MaterializedPaginationOrder, 20, 20); console.log(`Page 2: ${page2.length} orders`);
// Third page of 20 const page3 = await this.store.readModels.materialized.getInstances(MaterializedPaginationOrder, 40, 20); console.log(`Page 3: ${page3.length} orders`); }}Pagination Parameters
Section titled “Pagination Parameters”The parameters use strongly-typed concepts that convert implicitly from int:
| Parameter | Type | Default | Named Constants |
|---|---|---|---|
skip | InstanceCountToSkip? | 0 | InstanceCountToSkip.Zero |
take | InstanceCount? | 50 | InstanceCount.Default, InstanceCount.Unlimited |
using Cratis.Chronicle;using Cratis.Chronicle.ReadModels;
public class MaterializedPaginationNamedConstants(IEventStore eventStore){ public Task<IEnumerable<MaterializedPaginationOrder>> GetOrders() => // Using named constants eventStore.ReadModels.Materialized.GetInstances<MaterializedPaginationOrder>( skip: InstanceCountToSkip.Zero, take: InstanceCount.Default);}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class NamedConstantsOrder(val status: String = "", val total: Double = 0.0)
/** * `skip` and `take` both default to well-known values (0 and 50) - call without arguments to * use them rather than repeating the numbers yourself. */suspend fun getOrders(store: IEventStore): List<NamedConstantsOrder> = store.readModels.materialized.getInstances(NamedConstantsOrder::class)import io.cratis.chronicle.EventStore;import io.cratis.chronicle.readModels.ReadModel;
import java.util.List;
import io.cratis.chronicle.java.ReadModelsJavaBridge;
@ReadModelclass NamedConstantsOrder { private String status = ""; private double total = 0;
public String getStatus() { return status; } public void setStatus(String status) { this.status = status; }
public double getTotal() { return total; } public void setTotal(double total) { this.total = total; }}
class ReadModelsMaterializedPaginationNamedConstants { // skip: 0, take: 50 are the Kotlin client's built-in defaults - repeat them explicitly since // the Java bridge has no default-argument support. List<NamedConstantsOrder> getOrders(EventStore store) { return ReadModelsJavaBridge.getMaterializedInstances(store.getReadModels(), NamedConstantsOrder.class, 0, 50); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Building a Paged API Endpoint
Section titled “Building a Paged API Endpoint”using Cratis.Chronicle;using Microsoft.AspNetCore.Mvc;
[ApiController][Route("orders")]public class MaterializedPaginationOrdersController(IEventStore eventStore) : ControllerBase{ [HttpGet] public async Task<IEnumerable<MaterializedPaginationOrder>> GetOrders( [FromQuery] int page = 0, [FromQuery] int pageSize = 20) { return await eventStore.ReadModels.Materialized.GetInstances<MaterializedPaginationOrder>( skip: page * pageSize, take: pageSize); }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class PagedEndpointOrder(val status: String = "", val total: Double = 0.0)
/** * A paged read suitable for backing a list endpoint - only the requested page is loaded. */suspend fun getOrders(store: IEventStore, page: Int, pageSize: Int): List<PagedEndpointOrder> = store.readModels.materialized.getInstances(PagedEndpointOrder::class, skip = page * pageSize, take = pageSize)import io.cratis.chronicle.EventStore;import io.cratis.chronicle.readModels.ReadModel;
import java.util.List;
import io.cratis.chronicle.java.ReadModelsJavaBridge;
@ReadModelclass PagedEndpointOrder { private String status = ""; private double total = 0;
public String getStatus() { return status; } public void setStatus(String status) { this.status = status; }
public double getTotal() { return total; } public void setTotal(double total) { this.total = total; }}
class ReadModelsMaterializedPaginationPagedEndpoint { // A paged read suitable for backing a list endpoint - only the requested page is loaded. List<PagedEndpointOrder> getOrders(EventStore store, int page, int pageSize) { return ReadModelsJavaBridge.getMaterializedInstances( store.getReadModels(), PagedEndpointOrder.class, page * pageSize, pageSize); }}defmodule MyApp.MaterializedPaginationOrdersController do alias MyApp.ReadModels.MaterializedPaginationOrder
def get_orders(page \\ 0, page_size \\ 20) do with {:ok, result} <- Chronicle.ReadModels.query(MaterializedPaginationOrder, page: page, page_size: page_size ) do {:ok, result.instances} end endendTypeScript does not support this workflow yet.Observing Changes
Section titled “Observing Changes”For a complete live collection with optional client-side filtering, use Observing Read Models. IReadModels.Observe<T>() has no 50-instance default window; Materialized.ObserveInstances below observes only the requested page.
ObserveInstances returns an IObservable<IEnumerable<TReadModel>> that emits a new page snapshot whenever the underlying stored data changes. This is useful for live-updating UIs, dashboards, and monitoring tools.
using System.Linq;using Cratis.Chronicle;
public record MaterializedPaginationProduct(string Name, decimal Price);
public class MaterializedPaginationObserving(IEventStore eventStore){ public void Run() { var subscription = eventStore.ReadModels.Materialized .ObserveInstances<MaterializedPaginationProduct>(take: 50) .Subscribe(products => { // Called whenever the stored instances change Console.WriteLine($"Products updated: {products.Count()} in view"); });
// Dispose when done to release the change stream subscription.Dispose(); }}import io.cratis.chronicle.IEventStoreimport kotlinx.coroutines.coroutineScopeimport kotlinx.coroutines.launch
data class MaterializedPaginationProduct(val name: String = "", val price: Double = 0.0)
class MaterializedPaginationObserving(private val eventStore: IEventStore) { suspend fun run() = coroutineScope { val subscription = launch { eventStore.readModels.materialized .observeInstances(MaterializedPaginationProduct::class, take = 50) .collect { products -> // Called whenever the stored instances change println("Products updated: ${products.size} in view") } }
// Collect within the caller's scope; cancel that scope when observation ends. subscription.join() }}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.ReadModelsJavaBridge;
import kotlinx.coroutines.Job;
record MaterializedPaginationProduct(String name, double price) {}
class MaterializedPaginationObserving { private final IEventStore eventStore;
MaterializedPaginationObserving(IEventStore eventStore) { this.eventStore = eventStore; }
Job run() { Job subscription = ReadModelsJavaBridge.observeMaterializedInstances( eventStore.getReadModels(), MaterializedPaginationProduct.class, 0, 50, products -> { // Called whenever the stored instances change System.out.println("Products updated: " + products.size() + " in view"); });
// The owner cancels this job when it closes. return subscription; }}defmodule MyApp.MaterializedPaginationObserving do alias Chronicle.ReadModels alias MyApp.ReadModels.MaterializedPaginationOrder
def run do {:ok, observer} = ReadModels.observe(MaterializedPaginationOrder, page: 0, page_size: 50)
try do receive_pages() after ReadModels.unobserve(observer) end end
defp receive_pages do receive do {:chronicle_read_model_instances, MaterializedPaginationOrder, page} -> # Receives the initial stored page and each updated page. IO.puts("Orders updated: #{length(page.instances)} in view") receive_pages()
{:chronicle_read_model_observe_error, MaterializedPaginationOrder, reason} -> {:error, reason}
:stop -> :ok end endendimport { IEventStore } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
class MaterializedPaginationProduct { @field(String) readonly name: string; @field(Number) readonly price: number;
constructor(name: string, price: number) { this.name = name; this.price = price; }}
class MaterializedPaginationObserving { constructor(private readonly store: IEventStore) {}
async run(): Promise<void> { // Called whenever the stored instances change for await (const products of this.store.readModels.materialized.observeInstances(MaterializedPaginationProduct, 0, 50)) { console.log(`Products updated: ${products.length} in view`); } }}Observation relies on the sink’s change stream mechanism:
- MongoDB — uses native MongoDB change streams
- SQL (PostgreSQL, SQL Server, SQLite) — reads the current page on subscription, then polls the stored rows and total count for changes. Pages are ordered by the read model key. Set
Cratis:Chronicle:Sql:LiveQueryPollIntervalSecondsto change the polling interval (default: 2 seconds). Several writes between polls can appear as one updated snapshot; for projection-backed read models, useIReadModels.Watch<T>()if you need each projection changeset.
Observing in a Service
Section titled “Observing in a Service”using Cratis.Chronicle;
public class MaterializedPaginationProductDashboard : IDisposable{ readonly IDisposable _subscription;
public MaterializedPaginationProductDashboard(IEventStore eventStore) { _subscription = eventStore.ReadModels.Materialized .ObserveInstances<MaterializedPaginationProduct>(take: 100) .Subscribe(UpdateView); }
void UpdateView(IEnumerable<MaterializedPaginationProduct> products) { /* ... */ }
public void Dispose() => _subscription.Dispose();}import io.cratis.chronicle.IEventStoreimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.launch
/** * Observes a live paginated window of materialized instances for as long as [scope] is active. */class ProductDashboard(store: IEventStore, scope: CoroutineScope) { init { scope.launch { store.readModels.materialized.observeInstances(MaterializedPaginationProduct::class, take = 100) .collect { products -> updateView(products) } } }
private fun updateView(products: List<MaterializedPaginationProduct>) { // ... }}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.ReadModelsJavaBridge;
import kotlinx.coroutines.Job;
import java.util.List;
/** * Observes a live paginated window of materialized instances until {@code close()} releases it. */class ProductDashboard implements AutoCloseable { private final Job subscription;
ProductDashboard(IEventStore store) { subscription = ReadModelsJavaBridge.observeMaterializedInstances( store.getReadModels(), MaterializedPaginationProduct.class, 0, 100, this::updateView); }
private void updateView(List<MaterializedPaginationProduct> products) { // ... }
@Override public void close() { subscription.cancel(null); }}defmodule MyApp.MaterializedPaginationProductDashboard do use GenServer
alias Chronicle.ReadModels alias MyApp.ReadModels.MaterializedPaginationOrder
def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts)
@impl true def init(_opts) do case ReadModels.observe(MaterializedPaginationOrder, page: 0, page_size: 100) do {:ok, observer} -> {:ok, %{observer: observer, orders: []}} {:error, reason} -> {:stop, reason} end end
@impl true def handle_info({:chronicle_read_model_instances, MaterializedPaginationOrder, page}, state) do # Update the dashboard on the initial page and every subsequent snapshot. {:noreply, %{state | orders: page.instances}} end
def handle_info({:chronicle_read_model_observe_error, MaterializedPaginationOrder, reason}, state) do {:stop, reason, state} end
@impl true def terminate(_reason, state), do: ReadModels.unobserve(state.observer)endimport { IEventStore } from '@cratis/chronicle';
class MaterializedPaginationProductDashboard { constructor(private readonly store: IEventStore) {}
async start(updateView: (products: MaterializedPaginationProduct[]) => void): Promise<void> { for await (const products of this.store.readModels.materialized.observeInstances(MaterializedPaginationProduct, 0, 100)) { updateView(products); } }}When to Use the Materialized API
Section titled “When to Use the Materialized API”Use Materialized.GetInstances and ObserveInstances when:
- You need a page of stored read model instances for a list view, data grid, or infinite scroll UI
- You want real-time updates pushed to a connected UI without polling
- The dataset is large and loading everything into memory via event replay would be too slow or too expensive
Do not use the Materialized API when:
- You need to filter by a specific field — query the sink directly
- You need instances sorted by a property — query the sink directly
- You need a count of matching records — query the sink directly
- You need to run an aggregation — query the sink directly
Comparison with On-Demand GetInstances
Section titled “Comparison with On-Demand GetInstances”ReadModels.GetInstances<T>() | ReadModels.Materialized.GetInstances<T>() | |
|---|---|---|
| Data source | The sink for a materialized read model; an event log replay for a passive one, or when you pass an event count | Materialized sink (database) |
| Consistency | Eventual for a materialized read model; strong for a passive one | Eventual — milliseconds behind |
| Performance | Reads every stored instance, or replays every event for a passive one | O(1) — direct database lookup |
| Pagination | No | Yes — skip/take |
| Large datasets | Slow — returns everything in one call | Fast — loads only the requested page |
| Filtering/sorting | Post-fetch with LINQ | Not supported — query the sink directly |
Related Topics
Section titled “Related Topics”- Consistency Models — Understanding strong vs. eventual consistency
- Getting a Single Instance — On-demand computation for a single instance
- Getting a Collection of Instances — On-demand collection retrieval via event replay
- Watching Read Models — Observe event-log-sourced read model changesets
- Projections — How read models are produced from events
- Reducers — Imperative state-building from events
- MongoDB Sink — How read model instances are stored in MongoDB
- SQL Sink — How read model instances are stored in a SQL database