Materialized Views
Materialized views are pre-computed, query-optimized read models that aggregate data from event streams. While projections build read models in real-time as events occur, materialized views are schedule-driven and designed for complex queries, reporting, and analytics where staleness is acceptable.
Before You Start
- .NET 10.0
- Install the required packages:
dotnet add package Excalibur.EventSourcingdotnet add package Excalibur.EventSourcing.SqlServer # or your provider
- Familiarity with projections and event stores
Projections vs Materialized Views
| Aspect | Projections | Materialized Views |
|---|---|---|
| Trigger | Event-driven (real-time) | Schedule-driven (batched) |
| Purpose | General read models | Query-optimized analytics |
| Staleness | Near-zero | Configurable (seconds to hours) |
| Complexity | Single aggregate focus | Cross-aggregate aggregation |
| Use case | Order details, lists | Dashboards, reports, KPIs |
When to Use Materialized Views
Use materialized views when:
- Queries aggregate data across multiple aggregates or event streams
- Data can tolerate staleness (dashboards, reports)
- Building the view is expensive (complex joins, aggregations)
- You need to leverage native database features (indexed views, aggregation pipelines)
Architecture Overview
Key Concepts
How GetViewId Works
The GetViewId method extracts the view instance identifier from an event. It determines which specific view instance should be updated by a given event.
string? GetViewId(IDomainEvent @event);
The method returns string?:
- Returns a string → The processor loads that view instance, applies the event, and saves it
- Returns
null→ The event is skipped—no view is updated
Combined with ViewName, this forms a unique key: (ViewName, ViewId) → one view instance.
Example: Order Summary View
public class OrderSummaryViewBuilder : IMaterializedViewBuilder<OrderSummaryView>
{
public string ViewName => "OrderSummary";
public IReadOnlyList<Type> HandledEventTypes => new[]
{
typeof(OrderCreated),
typeof(OrderItemAdded),
typeof(OrderShipped)
};
public string? GetViewId(IDomainEvent @event) => @event switch
{
OrderCreated e => e.OrderId.ToString(),
OrderItemAdded e => e.OrderId.ToString(),
OrderShipped e => e.OrderId.ToString(),
_ => null // Skip unhandled events
};
public OrderSummaryView Apply(OrderSummaryView view, IDomainEvent @event) { ... }
}
Multiple Views on the Same Event Stream
What if you need multiple materialized views built from the same events? Each builder has its own:
ViewName— unique identifier for the view typeHandledEventTypes— the events it subscribes to- Position — tracked separately per
ViewName
When you register multiple builders, the processor iterates through all registered builders for each event. Each builder whose HandledEventTypes includes the event type will:
- Call
GetViewId(@event)to determine which view instance - If not null, load the view, apply the event, and save the view
Example: Same Event, Different Views
Consider an OrderCreated event with OrderId: 123, CustomerId: 456, and Amount: 99.99. Three different builders can each extract a different key from the same event:
// Builder 1: OrderSummaryViewBuilder
// Keyed by OrderId → Updates OrderSummaryView for order 123
public string? GetViewId(IDomainEvent @event) => @event switch
{
OrderCreated e => e.OrderId.ToString(), // "123"
_ => null
};
// Builder 2: CustomerOrderHistoryViewBuilder
// Keyed by CustomerId → Updates CustomerOrderHistoryView for customer 456
public string? GetViewId(IDomainEvent @event) => @event switch
{
OrderCreated e => e.CustomerId.ToString(), // "456"
_ => null
};
// Builder 3: DailyRevenueViewBuilder
// Keyed by date → Updates DailyRevenueView for Feb 5, 2026
public string? GetViewId(IDomainEvent @event) => @event switch
{
OrderCreated e => e.OccurredAt.ToString("yyyy-MM-dd"), // "2026-02-05"
_ => null
};
Each builder uses different keys from the same event to build different kinds of projections.
Position Tracking Per View
Position is tracked per ViewName, not globally. This enables independent catch-up for each view:
SELECT * FROM materialized_view_positions;
| view_name | position | updated_at |
|------------------------|----------|---------------------|
| OrderSummary | 15234 | 2026-02-05 12:00:00 |
| CustomerOrderHistory | 15234 | 2026-02-05 12:00:00 |
| DailyRevenue | 15100 | 2026-02-05 11:55:00 | -- lagging behind
This enables:
- Independent catch-up — Each view can process at its own pace
- Selective rebuild — Rebuild one view without affecting others
- Different refresh schedules — Critical views refresh more frequently
Core Interfaces
IMaterializedViewBuilder
Defines how to build a view from events:
public interface IMaterializedViewBuilder<TView>
where TView : class, new()
{
/// <summary>
/// Gets the name of this view builder for position tracking.
/// </summary>
string ViewName { get; }
/// <summary>
/// Gets the types of events this builder handles.
/// </summary>
IReadOnlyList<Type> HandledEventTypes { get; }
/// <summary>
/// Determines the view ID for an event.
/// </summary>
string? GetViewId(IDomainEvent @event);
/// <summary>
/// Applies an event to the view, updating its state.
/// </summary>
TView Apply(TView view, IDomainEvent @event);
/// <summary>
/// Creates a new instance of the view.
/// </summary>
TView CreateNew() => new();
}
IMaterializedViewStore
Handles persistence of materialized views:
public interface IMaterializedViewStore
{
/// <summary>
/// Gets a materialized view by its identifier.
/// </summary>
ValueTask<TView?> GetAsync<TView>(
string viewName,
string viewId,
CancellationToken cancellationToken)
where TView : class;
/// <summary>
/// Saves a materialized view.
/// </summary>
ValueTask SaveAsync<TView>(
string viewName,
string viewId,
TView view,
CancellationToken cancellationToken)
where TView : class;
/// <summary>
/// Deletes a materialized view.
/// </summary>
ValueTask DeleteAsync(
string viewName,
string viewId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the last processed position for a view.
/// </summary>
ValueTask<long?> GetPositionAsync(
string viewName,
CancellationToken cancellationToken);
/// <summary>
/// Saves the last processed position for a view.
/// </summary>
ValueTask SavePositionAsync(
string viewName,
long position,
CancellationToken cancellationToken);
}
Getting Started
1. Define Your View Model
public class OrderSummaryView
{
public string OrderId { get; set; } = string.Empty;
public string CustomerId { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
public int ItemCount { get; set; }
public string Status { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset LastUpdatedAt { get; set; }
}
2. Create a View Builder
public class OrderSummaryViewBuilder : IMaterializedViewBuilder<OrderSummaryView>
{
public string ViewName => "OrderSummary";
public IReadOnlyList<Type> HandledEventTypes { get; } = new[]
{
typeof(OrderCreated),
typeof(OrderItemAdded),
typeof(OrderCompleted)
};
public string? GetViewId(IDomainEvent @event) => @event switch
{
OrderCreated e => e.OrderId,
OrderItemAdded e => e.OrderId,
OrderCompleted e => e.OrderId,
_ => null
};
public OrderSummaryView Apply(OrderSummaryView view, IDomainEvent @event)
{
switch (@event)
{
case OrderCreated e:
view.OrderId = e.OrderId;
view.CustomerId = e.CustomerId;
view.Status = "Created";
view.CreatedAt = e.OccurredAt;
view.LastUpdatedAt = e.OccurredAt;
break;
case OrderItemAdded e:
view.TotalAmount += e.UnitPrice * e.Quantity;
view.ItemCount += e.Quantity;
view.LastUpdatedAt = e.OccurredAt;
break;
case OrderCompleted e:
view.Status = "Completed";
view.LastUpdatedAt = e.OccurredAt;
break;
}
return view;
}
}
3. Register Services
// Register the materialized view store (choose one provider)
services.AddSqlServerMaterializedViewStore(opts => opts.ConnectionString = connectionString);
// services.AddPostgresMaterializedViewStore(opts => opts.ConnectionString = connectionString);
services.AddMaterializedViews(builder =>
{
// Register view builders
builder.AddBuilder<OrderSummaryView, OrderSummaryViewBuilder>()
.AddBuilder<CustomerStatsView, CustomerStatsViewBuilder>();
// MongoDB and ElasticSearch have builder extensions:
// builder.UseMongoDb("mongodb://localhost:27017", "myapp");
// builder.UseElasticSearch("http://localhost:9200");
// Enable background refresh
builder.UseRefreshService(options =>
{
options.RefreshInterval = TimeSpan.FromSeconds(30);
// Or use cron: options.CronExpression = "*/5 * * * *";
options.CatchUpOnStartup = true;
options.MaxRetryCount = 3;
});
// Enable health checks and metrics
builder.WithHealthChecks()
.WithMetrics();
});
4. Query Views in Handlers
public class GetOrderSummaryHandler : IActionHandler<GetOrderSummaryQuery, OrderSummaryDto>
{
private readonly IMaterializedViewStore _viewStore;
public GetOrderSummaryHandler(IMaterializedViewStore viewStore)
{
_viewStore = viewStore;
}
public async Task<OrderSummaryDto> HandleAsync(
GetOrderSummaryQuery query,
CancellationToken ct)
{
var view = await _viewStore.GetAsync<OrderSummaryView>(
"OrderSummary",
query.OrderId,
ct);
if (view is null)
return OrderSummaryDto.NotFound;
return new OrderSummaryDto
{
OrderId = view.OrderId,
CustomerId = view.CustomerId,
TotalAmount = view.TotalAmount,
ItemCount = view.ItemCount,
Status = view.Status,
LastUpdatedAt = view.LastUpdatedAt
};
}
}
Store Providers
SQL Server
// Simple registration
services.AddSqlServerMaterializedViewStore(opts => opts.ConnectionString = connectionString);
// Or with fluent builder
services.AddMaterializedViews(builder =>
{
builder.AddBuilder<OrderSummaryView, OrderSummaryViewBuilder>()
.UseStore<SqlServerMaterializedViewStore>();
});
// With custom table names
services.AddSqlServerMaterializedViewStore(opts =>
{
opts.ConnectionString = connectionString;
opts.ViewTableName = "MyViews";
opts.PositionTableName = "MyViewPositions";
});
Storage: JSON in nvarchar(max) column with MERGE upserts.
Tables:
MaterializedViews(ViewName, ViewId, Data, CreatedAt, UpdatedAt)MaterializedViewPositions(ViewName, Position, CreatedAt, UpdatedAt)
PostgreSQL
services.AddPostgresMaterializedViewStore(opts => opts.ConnectionString = connectionString);
Storage: JSONB with INSERT ON CONFLICT upserts.
Tables: (snake_case per ADR-109)
materialized_views(view_name, view_id, data, created_at, updated_at)materialized_view_positions(view_name, position, created_at, updated_at)
MongoDB
services.AddMaterializedViews(builder =>
{
builder.UseMongoDb("mongodb://localhost:27017", "myapp");
// Or with options:
builder.UseMongoDb(options =>
{
options.ConnectionString = "mongodb://localhost:27017";
options.DatabaseName = "myapp";
options.ViewsCollectionName = "materialized_views";
options.PositionsCollectionName = "materialized_view_positions";
options.UseSsl = true;
});
});
Storage: BSON documents with composite IDs (viewName:viewId).
Collections:
materialized_viewsmaterialized_view_positions
Elasticsearch
services.AddMaterializedViews(builder =>
{
builder.UseElasticSearch("http://localhost:9200");
// Or with options:
builder.UseElasticSearch(options =>
{
options.NodeUri = "http://localhost:9200";
options.ViewsIndexName = "materialized-views";
options.PositionsIndexName = "materialized-view-positions";
options.Username = "elastic";
options.Password = "changeme";
// Or API key:
// options.ApiKey = "your-api-key";
});
});
Storage: JSON documents with automatic index creation.
Indices:
materialized-viewsmaterialized-view-positions
Background Refresh Service
The MaterializedViewRefreshService is a BackgroundService that periodically processes new events and updates views.
Configuration Options
builder.UseRefreshService(options =>
{
// Interval-based scheduling (default)
options.RefreshInterval = TimeSpan.FromSeconds(30);
// OR cron-based scheduling (takes precedence)
options.CronExpression = "*/5 * * * *"; // Every 5 minutes
// Catch up on startup
options.CatchUpOnStartup = true;
// Retry configuration
options.MaxRetryCount = 3;
options.InitialRetryDelay = TimeSpan.FromSeconds(1);
options.MaxRetryDelay = TimeSpan.FromSeconds(30);
// Enable/disable
options.Enabled = true;
});
Cron Expression Examples
| Expression | Schedule |
|---|---|
*/30 * * * * * | Every 30 seconds |
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour |
0 0 * * * | Daily at midnight |
0 0 * * 0 | Weekly on Sunday |
Health Checks
The MaterializedViewHealthCheck monitors view health and integrates with ASP.NET Core health checks.
Configuration
builder.WithHealthChecks(options =>
{
options.Name = "materialized-views";
options.Tags = new[] { "ready", "event-sourcing" };
options.StalenessThreshold = TimeSpan.FromMinutes(5);
options.FailureRateThresholdPercent = 10.0;
options.IncludeDetails = true;
});
Health States
| State | Condition |
|---|---|
| Healthy | All views current, failure rate acceptable |
| Degraded | Views stale OR failure rate exceeds threshold |
| Unhealthy | No views registered OR store unavailable |
Endpoint Setup
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
Response:
{
"status": "Healthy",
"checks": [
{
"name": "materialized-views",
"status": "Healthy",
"description": "3 materialized views healthy.",
"data": {
"registeredViews": 3,
"viewNames": ["OrderSummary", "CustomerStats", "ProductAnalytics"],
"maxStaleness": "00:00:45",
"failureRatePercent": 0
}
}
]
}
OpenTelemetry Metrics
The MaterializedViewMetrics class provides comprehensive observability.
Configuration
builder.WithMetrics();
Available Metrics
| Metric | Type | Description |
|---|---|---|
materialized_view.refresh.duration | Histogram | Duration of refresh operations (seconds) |
materialized_view.staleness | ObservableGauge | Time since last refresh per view (seconds) |
materialized_view.refresh.failures | Counter | Count of refresh failures |
materialized_view.state | ObservableGauge | View health (1=healthy, 0=unhealthy) |
Meter name: Excalibur.EventSourcing.MaterializedViews
OpenTelemetry Setup
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter("Excalibur.EventSourcing.MaterializedViews");
metrics.AddPrometheusExporter();
});
Best Practices
1. Design for Staleness
Always surface when data was last updated:
public class DashboardDto
{
public SalesData Sales { get; set; }
public DateTime DataAsOf { get; set; }
public bool IsStale { get; set; } // > 2x refresh interval
}
2. Choose Appropriate Refresh Intervals
| View Type | Suggested Interval |
|---|---|
| Real-time dashboards | 15-60 seconds |
| Operational reports | 5-15 minutes |
| Analytics dashboards | 1-4 hours |
| Historical reports | Daily |
3. Use Position Tracking
Position tracking ensures exactly-once processing:
// The framework handles this automatically, but for manual scenarios:
var position = await _store.GetPositionAsync("OrderSummary", ct);
// Process events from position...
await _store.SavePositionAsync("OrderSummary", newPosition, ct);
4. Handle Build Failures Gracefully
public async Task<TView?> GetViewWithFallbackAsync<TView>(
string viewName,
string viewId,
CancellationToken ct) where TView : class
{
var view = await _store.GetAsync<TView>(viewName, viewId, ct);
if (view is null)
{
_logger.LogWarning(
"View {ViewName}/{ViewId} not available",
viewName, viewId);
return default;
}
return view;
}
5. Monitor View Health
Configure alerts based on health check status and metrics:
// Prometheus alerting rule example
groups:
- name: materialized-views
rules:
- alert: MaterializedViewStale
expr: materialized_view_staleness > 300
for: 5m
labels:
severity: warning
annotations:
summary: "Materialized view is stale"
Advanced Scenarios
Single Store Per Application
The framework registers one IMaterializedViewStore per application (using TryAddSingleton). The first registration wins — subsequent registrations are silently ignored. Choose the store that best fits your primary query workload:
// Pick ONE store provider
services.AddSqlServerMaterializedViewStore(sqlConnectionString);
// OR: services.AddPostgresMaterializedViewStore(pgConnectionString);
// OR: builder.UseMongoDb("mongodb://localhost:27017", "myapp");
// OR: builder.UseElasticSearch("http://elasticsearch:9200");
If you need views in multiple stores (e.g., SQL Server for transactional queries + Elasticsearch for full-text search), use projections for the secondary store — each projection store is registered per TProjection type and doesn't conflict.
The Default Processor (MaterializedViewProcessor)
The framework provides a built-in MaterializedViewProcessor that handles the full lifecycle:
- Event routing — builds an event-type → builder routing map at startup from
HandledEventTypes - Single event processing — routes one event to all matching builders, saves position per view
- Batch processing — processes multiple events with deferred position saves (one per batch, not per event)
- Catch-up — reads the global event stream from the last saved position for a specific view
- Rebuild — resets all positions to zero and replays the entire global stream
The processor is registered automatically when you call AddMaterializedViews:
services.AddMaterializedViews(builder =>
{
builder.AddBuilder<OrderSummaryView, OrderSummaryViewBuilder>()
.AddBuilder<CustomerStatsView, CustomerStatsViewBuilder>();
});
Catch-Up and Rebuild
Use the processor directly for on-demand operations:
public class ViewMaintenanceController : ControllerBase
{
private readonly IMaterializedViewProcessor _processor;
public ViewMaintenanceController(IMaterializedViewProcessor processor)
=> _processor = processor;
[HttpPost("views/{viewName}/catch-up")]
public async Task<IActionResult> CatchUp(string viewName, CancellationToken ct)
{
// Reads from last saved position + 1 for this view
await _processor.CatchUpAsync(viewName, ct);
return Ok();
}
[HttpPost("views/rebuild")]
public async Task<IActionResult> RebuildAll(CancellationToken ct)
{
// Resets ALL view positions to 0 and replays the entire global stream
await _processor.RebuildAsync(ct);
return Ok();
}
}
Batch Processing
For high-throughput scenarios (e.g., CDC integration), use the batch API:
var events = new List<(IDomainEvent Event, long Position)>
{
(orderCreated, 100),
(orderItemAdded, 101),
(orderShipped, 102)
};
// Processes all events, saves position once per affected view at the end
await processor.ProcessEventsAsync(events, cancellationToken);
The batch API defers position saves until the entire batch is processed — significantly more efficient than processing events one at a time.
Configuration
services.AddMaterializedViews(builder =>
{
builder.AddBuilder<OrderSummaryView, OrderSummaryViewBuilder>();
// Configure batch processing options
builder.Configure(options =>
{
options.BatchSize = 500; // Events per batch during catch-up/rebuild
options.BatchDelay = TimeSpan.FromMilliseconds(50); // Delay between batches to avoid overwhelming the store
});
});
Scheduled Rebuilds with ProjectionRebuildJob
For periodic full rebuilds of all materialized views, use ProjectionRebuildJob from the Excalibur.Jobs package. This job resolves IMaterializedViewProcessor from DI and calls RebuildAsync(), which resets all view positions to zero and replays the entire global event stream.
dotnet add package Excalibur.Jobs
Registration:
services.AddQuartzWithJobs(quartz =>
{
// Rebuild all materialized views daily at 3 AM
quartz.AddJob<ProjectionRebuildJob>("0 0 3 * * ?");
// Or weekly on Sunday at midnight
// quartz.AddJob<ProjectionRebuildJob>("0 0 0 ? * SUN");
});
How it works:
- The job creates a DI scope and resolves
IMaterializedViewProcessor - If no processor is registered, it logs a warning and exits gracefully
- Calls
processor.RebuildAsync(cancellationToken)which:- Resets all view positions to 0
- Replays the entire global event stream
- Regenerates all materialized view data
ProjectionRebuildJob replays all events in the global stream. For large event stores (millions+ events), this can take significant time. Schedule during low-traffic periods and monitor via the materialized view metrics.
When to use scheduled rebuilds:
| Scenario | Recommendation |
|---|---|
| Schema migration (new view fields) | One-time rebuild via AddOneTimeJob<ProjectionRebuildJob>() |
| Data integrity assurance | Weekly or daily scheduled rebuild |
| Bug fix in view builder logic | One-time rebuild after deploying the fix |
| New view added to existing system | The refresh service catches up automatically — no rebuild needed |
Graceful degradation: If IMaterializedViewProcessor is not registered (e.g., materialized views are disabled in a deployment), the job logs a warning and completes without error. This makes it safe to include in all deployment configurations.
Custom Processors
To replace the default processor with custom logic, implement IMaterializedViewProcessor:
public interface IMaterializedViewProcessor
{
Task ProcessEventAsync(IDomainEvent @event, long position, CancellationToken cancellationToken);
Task ProcessEventsAsync(IEnumerable<(IDomainEvent Event, long Position)> events, CancellationToken cancellationToken);
Task CatchUpAsync(string viewName, CancellationToken cancellationToken);
Task RebuildAsync(CancellationToken cancellationToken);
}
Register your custom processor:
services.AddMaterializedViews(builder =>
{
builder.AddBuilder<OrderSummaryView, OrderSummaryViewBuilder>();
builder.UseProcessor<MyCustomProcessor>(); // Replaces the default MaterializedViewProcessor
});
UseProcessor<T>() must be called inside the AddMaterializedViews configure action. The default processor registers after the configure action runs, using TryAddSingleton — so your custom processor takes precedence.
Next Steps
- Projections — Event-driven read models
- Event Store — Core event persistence
- Data Providers — Provider-specific features
- Health Checks — Monitoring infrastructure
See Also
- Projections - Event-driven read models for real-time query optimization
- Event Sourcing Overview - Core concepts and getting started with event sourcing
- CDC Pattern - Change Data Capture for async event processing