Skip to main content

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.EventSourcing
    dotnet add package Excalibur.EventSourcing.SqlServer # or your provider
  • Familiarity with projections and event stores

Projections vs Materialized Views

AspectProjectionsMaterialized Views
TriggerEvent-driven (real-time)Schedule-driven (batched)
PurposeGeneral read modelsQuery-optimized analytics
StalenessNear-zeroConfigurable (seconds to hours)
ComplexitySingle aggregate focusCross-aggregate aggregation
Use caseOrder details, listsDashboards, 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:

  1. ViewName — unique identifier for the view type
  2. HandledEventTypes — the events it subscribes to
  3. 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:

  1. Call GetViewId(@event) to determine which view instance
  2. 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_views
  • materialized_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-views
  • materialized-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

ExpressionSchedule
*/30 * * * * *Every 30 seconds
*/5 * * * *Every 5 minutes
0 * * * *Every hour
0 0 * * *Daily at midnight
0 0 * * 0Weekly 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

StateCondition
HealthyAll views current, failure rate acceptable
DegradedViews stale OR failure rate exceeds threshold
UnhealthyNo 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

MetricTypeDescription
materialized_view.refresh.durationHistogramDuration of refresh operations (seconds)
materialized_view.stalenessObservableGaugeTime since last refresh per view (seconds)
materialized_view.refresh.failuresCounterCount of refresh failures
materialized_view.stateObservableGaugeView 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 TypeSuggested Interval
Real-time dashboards15-60 seconds
Operational reports5-15 minutes
Analytics dashboards1-4 hours
Historical reportsDaily

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");
Hybrid Architectures

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:

  1. The job creates a DI scope and resolves IMaterializedViewProcessor
  2. If no processor is registered, it logs a warning and exits gracefully
  3. Calls processor.RebuildAsync(cancellationToken) which:
    • Resets all view positions to 0
    • Replays the entire global event stream
    • Regenerates all materialized view data
Long-Running Operation

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:

ScenarioRecommendation
Schema migration (new view fields)One-time rebuild via AddOneTimeJob<ProjectionRebuildJob>()
Data integrity assuranceWeekly or daily scheduled rebuild
Bug fix in view builder logicOne-time rebuild after deploying the fix
New view added to existing systemThe 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
});
DI Registration Order

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

See Also