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.

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

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

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

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

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

Observation relies on the sink’s change stream mechanism:

  • MongoDB — uses native MongoDB change streams
  • SQL — uses polling-based change detection via DbContext
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();
}

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 sourceEvent log replayMaterialized sink (database)
ConsistencyStrong — always currentEventual — milliseconds behind
PerformanceProportional to event historyO(1) — direct database lookup
PaginationNoYes — skip/take
Large datasetsSlow — replays all eventsFast — loads only the requested page
Filtering/sortingPost-fetch with LINQNot supported — query the sink directly