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:

// Inject IEventStore, then reach through to the Materialized API
var instances = await eventStore.ReadModels.Materialized.GetInstances<Order>();

Retrieve the first page of stored instances using the defaults (skip: 0, take: 50):

var instances = await eventStore.ReadModels.Materialized.GetInstances<Order>();

Both skip and take are optional with sensible defaults. Use them for page-based or offset-based navigation:

// First page of 20
var page1 = await eventStore.ReadModels.Materialized.GetInstances<Order>(take: 20);
// Second page of 20
var page2 = await eventStore.ReadModels.Materialized.GetInstances<Order>(skip: 20, take: 20);
// Third page of 20
var page3 = await eventStore.ReadModels.Materialized.GetInstances<Order>(skip: 40, take: 20);

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

ParameterTypeDefaultNamed Constants
skipInstanceCountToSkip?0InstanceCountToSkip.Zero
takeInstanceCount?50InstanceCount.Default, InstanceCount.Unlimited
// Using named constants
var instances = await eventStore.ReadModels.Materialized.GetInstances<Order>(
skip: InstanceCountToSkip.Zero,
take: InstanceCount.Default);
[HttpGet]
public async Task<IEnumerable<Order>> GetOrders(
[FromQuery] int page = 0,
[FromQuery] int pageSize = 20)
{
return await _eventStore.ReadModels.Materialized.GetInstances<Order>(
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.

var subscription = eventStore.ReadModels.Materialized
.ObserveInstances<Product>(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
public class ProductDashboard : IDisposable
{
readonly IDisposable _subscription;
public ProductDashboard(IEventStore eventStore)
{
_subscription = eventStore.ReadModels.Materialized
.ObserveInstances<Product>(take: 100)
.Subscribe(UpdateView);
}
void UpdateView(IEnumerable<Product> 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