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.
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 defstruct [:customer_name, :total]end
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';
class MaterializedPaginationOrder { constructor( readonly customerName: string, readonly total: number ) {}}
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 # Page 1 of 20 {:ok, page1} = Chronicle.ReadModels.query(MaterializedPaginationOrder, page: 1, page_size: 20) IO.puts("Page 1: #{length(page1.instances)} orders")
# Page 2 of 20 {:ok, page2} = Chronicle.ReadModels.query(MaterializedPaginationOrder, page: 2, page_size: 20) IO.puts("Page 2: #{length(page2.instances)} orders")
# Page 3 of 20 {:ok, page3} = Chronicle.ReadModels.query(MaterializedPaginationOrder, page: 3, 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); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Observing Changes
Section titled “Observing Changes”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") } }
// Cancel when done to release the change stream subscription.cancel() }}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; }
void 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"); });
// Cancel when done to release the change stream subscription.cancel(null); }}defmodule MyApp.MaterializedPaginationObserving do alias Chronicle.ReadModels alias MyApp.ReadModels.MaterializedPaginationOrder
# Elixir has no single "ObserveInstances" call — combine watch/2 # (per-instance change notifications) with query/2 (the current # materialized page) to get the same "re-emit the page whenever the # stored data changes" behavior as ObserveInstances(take: 50). def run do {:ok, watcher} = ReadModels.watch(MaterializedPaginationOrder)
receive do {:chronicle_read_model_changed, MaterializedPaginationOrder, _changeset} -> {:ok, page} = ReadModels.query(MaterializedPaginationOrder, page: 1, page_size: 50) IO.puts("Orders updated: #{length(page.instances)} in view") after 5_000 -> :timeout end
# Stop watching once done, to release the change stream. ReadModels.unwatch(watcher) endendimport { IEventStore } from '@cratis/chronicle';
class MaterializedPaginationProduct { constructor( readonly name: string, readonly price: number ) {}}
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 — uses polling-based change detection via
DbContext
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); }}Elixir does not support this workflow yet.import { 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 | Event log replay | Materialized sink (database) |
| Consistency | Strong — always current | Eventual — milliseconds behind |
| Performance | Proportional to event history | O(1) — direct database lookup |
| Pagination | No | Yes — skip/take |
| Large datasets | Slow — replays all events | 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