Skip to content

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.

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.

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;
}
}

View C# snippet source on GitHub

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>();
}

View C# snippet source on GitHub

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");
}
}

View C# snippet source on GitHub

The parameters use strongly-typed concepts that convert implicitly from int:

ParameterTypeDefaultNamed Constants
skipInstanceCountToSkip?0InstanceCountToSkip.Zero
takeInstanceCount?50InstanceCount.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);
}

View C# snippet source on GitHub

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);
}
}

View C# snippet source on GitHub

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();
}
}

View C# snippet source on GitHub

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:LiveQueryPollIntervalSeconds to change the polling interval (default: 2 seconds). Several writes between polls can appear as one updated snapshot; for projection-backed read models, use IReadModels.Watch<T>() if you need each projection changeset.
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();
}

View C# snippet source on GitHub

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
ReadModels.GetInstances<T>()ReadModels.Materialized.GetInstances<T>()
Data sourceThe sink for a materialized read model; an event log replay for a passive one, or when you pass an event countMaterialized sink (database)
ConsistencyEventual for a materialized read model; strong for a passive oneEventual — milliseconds behind
PerformanceReads every stored instance, or replays every event for a passive oneO(1) — direct database lookup
PaginationNoYes — skip/take
Large datasetsSlow — returns everything in one callFast — loads only the requested page
Filtering/sortingPost-fetch with LINQNot supported — query the sink directly