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();
/// <summary>
/// Declares the delivery guarantee this projection requires from its view store.
/// Defaults to <see cref="ViewDeliverySemantics.ExactlyOnce"/>.
/// </summary>
ViewDeliverySemantics DeliverySemantics => ViewDeliverySemantics.ExactlyOnce;
}
Delivery semantics
A projection declares the delivery guarantee it needs from its view store via DeliverySemantics.
The guarantee is an intrinsic property of the Apply logic — whether re-applying the same event
changes the view — so the projection author declares it, not the deployment.
ViewDeliverySemantics | Meaning | Runs on |
|---|---|---|
ExactlyOnce (default) | Accumulating / non-idempotent — re-applying an event changes the view (e.g. a running total). | An IAtomicMaterializedViewStore only — the view and its checkpoint are persisted as one atomic operation, so a crash between the two writes cannot replay the last event and double-count. Wiring an exactly-once projection to a non-atomic store is refused at startup. |
AtLeastOnceIdempotent | Idempotent (upsert-by-view-id) — re-applying an event yields the same view (e.g. status = Shipped). | Any view store, including Elasticsearch and OpenSearch, which have no cross-index transaction and so cannot persist a view and its checkpoint atomically. |
Declare at-least-once semantics when your Apply is a pure upsert, so the view can run on a
non-atomic store that tolerates the at-least-once replay a crash may cause:
public class OrderStatusView : IMaterializedViewBuilder<OrderStatusReadModel>
{
public ViewDeliverySemantics DeliverySemantics => ViewDeliverySemantics.AtLeastOnceIdempotent;
// Apply is an idempotent upsert — replaying OrderShipped is a no-op once status is Shipped.
public OrderStatusReadModel Apply(OrderStatusReadModel view, IDomainEvent @event)
{
if (@event is OrderShipped) view.Status = "Shipped";
return view;
}
// ... ViewName, HandledEventTypes, GetViewId ...
}
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);
}
IAtomicMaterializedViewStore
Exactly-once projections (ViewDeliverySemantics.ExactlyOnce) require a view store that can persist the
view and advance its processed position as one all-or-nothing operation. That capability is a
separate interface, IAtomicMaterializedViewStore : IMaterializedViewStore, not a virtual member on
the base store:
public interface IAtomicMaterializedViewStore : IMaterializedViewStore
{
// True when this store instance can currently commit the view and its position as one unit.
bool SupportsAtomicWrites { get; }
// Persists the view and advances the position in a single durable, monotonic operation:
// both writes commit, or neither does. A lower position never overwrites a higher one,
// so a delayed or retried write cannot rewind the checkpoint and replay applied events.
ValueTask SaveViewAndPositionAsync<TView>(
string viewName,
string viewId,
TView view,
long position,
CancellationToken cancellationToken)
where TView : class;
}
Why a separate type rather than a default method? Writing the view and the position as two operations and crashing between them replays the event on restart, which double-counts any view that accumulates rather than overwrites. A store with no way to commit both writes together must be unable to express the guarantee — so it simply does not implement this interface, rather than silently inheriting a default it cannot honour.
Capability vs. configuration — two distinct gates. Implementing the interface says the store is
capable of atomic writes; SupportsAtomicWrites says the current configuration enables them:
- A store whose atomicity is unconditional returns
trueconstantly. - A store whose atomicity requires an opt-in (a transaction mode, a replica set) returns the value of that setting, so the check reads the option that actually governs the behaviour instead of a proxy for it. A deployment that leaves the option off is refused at startup rather than degrading to at-least-once in production.
Which first-party stores qualify:
| Store | Atomic view+position? | SupportsAtomicWrites |
|---|---|---|
| SQL Server | Yes | true — unconditional (single-statement MERGE). |
| PostgreSQL | Yes | true — unconditional (INSERT … ON CONFLICT). |
| MongoDB | Conditional | reflects UseTransactions — requires a replica set / transactions; false on a standalone deployment. |
| Elasticsearch | No | — the view and its checkpoint live in different indices with no cross-index transaction, so the store does not implement the interface. |
| OpenSearch | No | — same as Elasticsearch. |
What the startup rejection means. Wiring an ExactlyOnce projection to a store that does not
implement IAtomicMaterializedViewStore — or one whose SupportsAtomicWrites is false — is refused
at startup, not at runtime. This is intended: the guarantee cannot be provided, so the failure is
surfaced immediately instead of silently downgrading to at-least-once and double-counting in production.
Resolve it by either (a) targeting an atomic store (SQL Server, PostgreSQL, or MongoDB with transactions
enabled), or (b) declaring ViewDeliverySemantics.AtLeastOnceIdempotent when your Apply is an
idempotent upsert (see Delivery semantics), which runs on any store.
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)
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 is what lets a projection resume without reprocessing from the beginning. On its own it
does not give you exactly-once — as described above, that additionally requires an
IAtomicMaterializedViewStore, which persists the view and its checkpoint in one atomic operation. Without
that atomicity, a crash between the two writes replays the last event and double-counts, whatever the
position says:
// 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