Snapshots
Snapshots optimize loading for aggregates with many events by storing periodic state captures.
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 event stores and repositories
Overview
Configuration
Basic Setup
// Recommended: Builder-integrated registration
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(es =>
{
es.UseSqlServer(sql =>
{
sql.ConnectionString(connectionString);
});
es.UseIntervalSnapshots(100); // Every 100 events
}));
// Alternative: Register stores separately
services.AddSqlServerEventStore(opts => opts.ConnectionString = connectionString);
services.AddSqlServerSnapshotStore(opts => opts.ConnectionString = connectionString);
Snapshot Strategies
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
// Interval-based (default) - snapshot every N events
builder.UseIntervalSnapshots(100);
// Time-based - snapshot if older than duration
builder.UseTimeBasedSnapshots(TimeSpan.FromDays(7));
// Size-based - snapshot when aggregate exceeds size threshold
builder.UseSizeBasedSnapshots(maxSizeInBytes: 1024 * 1024); // 1MB
// Composite - combine multiple strategies
builder.UseCompositeSnapshotStrategy(s => s
.AddIntervalStrategy(100)
.AddTimeBasedStrategy(TimeSpan.FromDays(7))
.RequireAll(false)); // Any condition triggers snapshot
// No snapshots (explicit opt-out)
builder.UseNoSnapshots();
// Custom strategy
builder.AddSnapshotStrategy<MyCustomStrategy>();
}));
Auto-Snapshot Policies
Auto-snapshots evaluate after every SaveAsync and create snapshots automatically when configured thresholds are met. Snapshot creation is best-effort -- if it fails, the save still succeeds.
Basic Setup
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
builder.UseAutoSnapshots(options =>
{
options.EventCountThreshold = 100; // Snapshot every 100 events
});
}));
Available Thresholds
Configure one or more thresholds on AutoSnapshotOptions. When any threshold is met, a snapshot is created:
| Threshold | Type | Description |
|---|---|---|
EventCountThreshold | int? | Snapshot after N events since the last snapshot |
TimeThreshold | TimeSpan? | Snapshot if the last snapshot is older than this duration |
VersionThreshold | long? | Snapshot when the aggregate version reaches this value |
CustomPolicy | Func<SnapshotDecisionContext, bool>? | Custom decision logic for advanced scenarios |
All thresholds are null (disabled) by default. When all are null, auto-snapshots have zero overhead.
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
builder.UseAutoSnapshots(options =>
{
options.EventCountThreshold = 100;
options.TimeThreshold = TimeSpan.FromHours(1);
// Any single match triggers a snapshot
});
}));
Per-Aggregate Overrides
Different aggregate types often need different snapshot frequencies. Use UseAutoSnapshots<TAggregate>() to configure per-type thresholds that override the global defaults:
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
// Global default: snapshot every 100 events
builder.UseAutoSnapshots(options =>
{
options.EventCountThreshold = 100;
});
// Orders snapshot more frequently (high-volume aggregate)
builder.UseAutoSnapshots<OrderAggregate>(options =>
{
options.EventCountThreshold = 50;
options.TimeThreshold = TimeSpan.FromHours(1);
});
// Inventory uses a custom policy
builder.UseAutoSnapshots<InventoryAggregate>(options =>
{
options.CustomPolicy = context =>
context.EventsSinceSnapshot >= 200
|| context.CurrentVersion % 500 == 0;
});
}));
Per-aggregate options use named IOptionsMonitor<AutoSnapshotOptions> keyed by aggregate type name.
Custom Policy
The CustomPolicy function receives a SnapshotDecisionContext with full aggregate state information:
public sealed record SnapshotDecisionContext(
string AggregateId,
string AggregateType,
long CurrentVersion,
long? LastSnapshotVersion, // null if never snapshotted
DateTimeOffset? LastSnapshotTimestamp, // null if never snapshotted
int EventsSinceSnapshot);
How It Works
- After
SaveAsynccommits events and runs inline projections, the repository evaluates the auto-snapshot policy. - If any threshold is met,
aggregate.CreateSnapshot()is called and the snapshot is saved. - If the snapshot save fails, the error is logged at
Warninglevel but does not propagate -- the original save already succeeded. - Aggregates at version 1 (just created) are never auto-snapshotted.
Observability
Auto-snapshot operations emit OpenTelemetry-compatible metrics via the Excalibur.EventSourcing.AutoSnapshot Meter:
| Metric | Type | Description |
|---|---|---|
excalibur.eventsourcing.auto_snapshot.created | Counter | Successfully created auto-snapshots |
excalibur.eventsourcing.auto_snapshot.failed | Counter | Failed auto-snapshot attempts |
excalibur.eventsourcing.auto_snapshot.evaluated | Counter | Policy evaluation count |
Auto-snapshots (UseAutoSnapshots) are the recommended approach for most applications. They integrate directly into SaveAsync and require no background services. Use the older UseIntervalSnapshots / UseTimeBasedSnapshots strategies only if you need the ISnapshotStrategy interface for custom tooling.
Implementing Snapshots
Define Snapshot State Type
Define a serializable type for your aggregate's state. This is NOT the ISnapshot interface - it's your domain state that will be serialized into the snapshot's Data property:
// Domain state to be serialized - NOT ISnapshot
public record OrderSnapshotState
{
public required Guid OrderId { get; init; }
public required string CustomerId { get; init; }
public required OrderStatus Status { get; init; }
public required decimal TotalAmount { get; init; }
public required List<OrderLineState> Lines { get; init; }
}
public record OrderLineState(
Guid LineId,
string ProductId,
int Quantity,
decimal UnitPrice);
Override Snapshot Methods
The ISnapshot interface uses ReadOnlyMemory<byte> Data for serialized state, ensuring immutability. Use the built-in Snapshot record:
public class Order : AggregateRoot<Guid>
{
public string CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
private readonly List<OrderLine> _lines = [];
// Override to create snapshot from current state
public override ISnapshot CreateSnapshot()
{
// Create domain state object
var state = new OrderSnapshotState
{
OrderId = Id,
CustomerId = CustomerId,
Status = Status,
TotalAmount = TotalAmount,
Lines = _lines.Select(l => new OrderLineState(
l.Id, l.ProductId, l.Quantity, l.UnitPrice
)).ToList()
};
// Serialize state to bytes and wrap in Snapshot using factory method
return Snapshot.Create(
aggregateId: Id.ToString(),
version: Version,
data: JsonSerializer.SerializeToUtf8Bytes(state),
aggregateType: nameof(Order));
}
// Override to restore state from snapshot
protected override void ApplySnapshot(ISnapshot snapshot)
{
// Deserialize state from snapshot Data
var state = JsonSerializer.Deserialize<OrderSnapshotState>(snapshot.Data)
?? throw new InvalidOperationException("Failed to deserialize snapshot");
Id = state.OrderId;
CustomerId = state.CustomerId;
Status = state.Status;
TotalAmount = state.TotalAmount;
_lines.Clear();
_lines.AddRange(state.Lines.Select(l =>
new OrderLine(l.LineId, l.ProductId, l.Quantity, l.UnitPrice)));
}
// ... rest of aggregate implementation
}
Note: The base
LoadFromSnapshotmethod handles version assignment and callsApplySnapshot. OverrideApplySnapshot(notLoadFromSnapshot) to restore your aggregate's state.
Repository Integration
The repository automatically uses snapshots:
public class EventSourcedRepository<TAggregate, TKey> : IEventSourcedRepository<TAggregate, TKey>
where TAggregate : AggregateRoot<TKey>
{
public async Task<TAggregate?> GetByIdAsync(TKey id, CancellationToken ct)
{
var aggregateId = id!.ToString()!;
TAggregate aggregate;
// Try to load snapshot first
var snapshot = await _snapshotStore.GetLatestSnapshotAsync(
aggregateId, typeof(TAggregate).Name, ct);
if (snapshot is not null)
{
aggregate = CreateInstance();
aggregate.LoadFromSnapshot(snapshot);
// Load only events after snapshot
var events = await _eventStore.LoadAsync(
aggregateId, typeof(TAggregate).Name, snapshot.Version, ct);
aggregate.LoadFromHistory(DeserializeEvents(events));
return aggregate;
}
// No snapshot - load all events
var allEvents = await _eventStore.LoadAsync(
aggregateId, typeof(TAggregate).Name, ct);
if (!allEvents.Any())
return null;
aggregate = CreateInstance();
aggregate.LoadFromHistory(DeserializeEvents(allEvents));
return aggregate;
}
public async Task SaveAsync(TAggregate aggregate, CancellationToken ct)
{
// Append events
var uncommittedEvents = aggregate.GetUncommittedEvents();
var result = await _eventStore.AppendAsync(
aggregate.Id,
aggregate.AggregateType,
uncommittedEvents,
aggregate.Version - uncommittedEvents.Count,
ct);
if (!result.Success)
throw new ConcurrencyException(result.ErrorMessage);
aggregate.MarkEventsAsCommitted();
// Check if snapshot needed
if (_snapshotStrategy.ShouldCreateSnapshot(aggregate))
{
var snapshot = aggregate.CreateSnapshot();
await _snapshotStore.SaveSnapshotAsync(snapshot, ct);
}
}
}
Snapshot Store Interface
public interface ISnapshotStore
{
ValueTask<ISnapshot?> GetLatestSnapshotAsync(
string aggregateId,
string aggregateType,
CancellationToken cancellationToken);
ValueTask SaveSnapshotAsync(
ISnapshot snapshot,
CancellationToken cancellationToken);
ValueTask DeleteSnapshotsAsync(
string aggregateId,
string aggregateType,
CancellationToken cancellationToken);
ValueTask DeleteSnapshotsOlderThanAsync(
string aggregateId,
string aggregateType,
long olderThanVersion,
CancellationToken cancellationToken);
}
Note: Methods return
ValueTaskto avoid allocations for synchronous completions.
Database Schema
The relational snapshot stores do not create their table at runtime. Provision it before the
first snapshot is saved, by running the script shipped inside the provider package under scripts/:
| Package | Script |
|---|---|
Excalibur.EventSourcing.SqlServer | scripts/002_CreateSnapshotSchema.sql |
Excalibur.EventSourcing.Postgres | scripts/001_CreateSnapshotSchema.sql |
Excalibur.EventSourcing.Oracle | scripts/001_CreateSnapshotSchema.sql |
Each script is the authoritative definition for its provider and carries the reasoning for every column inline — why the primary key includes the tenant, why the tenant column takes no default, and which column types are load-bearing. The schema is deliberately not reproduced on this page: a copy here would drift from the script you actually run, and the two would disagree without either one looking wrong.
The document and blob-backed stores — Cosmos DB, DynamoDB, Firestore, MongoDB, Redis, and the blob providers — create their own containers and need no schema step.
Excalibur.EventSourcing.Sqlite creates its own table on first use and keeps it current, so no schema
step is required to get started. Its DDL still ships, for the deployment that provisions ahead of the
application: scripts/001_CreateEventStoreSchema.sql creates the snapshot table alongside the event
table. Take the schema from that script rather than from another provider's — the column types differ.
A database provisioned by an earlier prerelease has no in-place upgrade path; re-provision it from the shipped script.
On a single-tenant host — one that has not registered multi-tenancy — the store converges rows
under the reserved untenanted key onto that host's single-tenant identity the first time it opens the
database, provided it holds CREATE TABLE. A host that requires a tenant skips that step, so its
genuinely-untenanted system rows stay where they are. The step is idempotent.
The single case that stops rather than guessing is a table holding both representations for the same aggregate — an empty-string row and a reserved-key row. Those two rows would have to become one, so the store refuses at startup, names the table and the aggregate, and leaves every row untouched. Delete or re-key whichever snapshot is stale and start the application again.
If you provisioned the table by hand
Compare it against the shipped script before upgrading. Two columns are worth checking specifically, because both fail at runtime rather than at provisioning time:
CreatedAtisDATETIMEOFFSETon SQL Server, notDATETIME2. The read path materialises aDateTimeOffset, so aDATETIME2column matches no constructor and every snapshot read fails outright — not just reads of rows written after the change.TenantIdis part of the primary key, alongsideAggregateIdandAggregateType. Keyed on the aggregate alone, two tenants holding the same aggregate id collide onto one row and one tenant's save overwrites the other's.
Custom Snapshot Strategies
public class AdaptiveSnapshotStrategy : ISnapshotStrategy
{
public int MinInterval { get; init; } = 50;
public int MaxInterval { get; init; } = 200;
public int EventSizeThreshold { get; init; } = 1000; // bytes
public bool ShouldCreateSnapshot<T>(T aggregate) where T : IAggregateRoot
{
var eventCount = aggregate.UncommittedEvents.Count;
var version = aggregate.Version;
// More frequent snapshots for aggregates with large events
var averageEventSize = EstimateAverageEventSize(aggregate.UncommittedEvents);
var interval = averageEventSize > EventSizeThreshold
? MinInterval
: MaxInterval;
return version > 0 && version % interval == 0;
}
}
Snapshot Versioning
Handle snapshot schema changes by versioning your snapshot state types. Since snapshots store serialized ReadOnlyMemory<byte> data, you can evolve the schema by handling deserialization gracefully.
Current Approach: Graceful Deserialization
// Version your snapshot state types (NOT ISnapshot - that's the envelope)
public record OrderSnapshotStateV1
{
public Guid OrderId { get; init; }
public string CustomerId { get; init; }
public decimal TotalAmount { get; init; }
}
public record OrderSnapshotStateV2
{
public Guid OrderId { get; init; }
public string CustomerId { get; init; }
public decimal TotalAmount { get; init; }
// V2 adds new field with default
public Address? ShippingAddress { get; init; }
}
// In your aggregate, handle both versions during deserialization
protected override void ApplySnapshot(ISnapshot snapshot)
{
// Try V2 first (current version)
var state = TryDeserialize<OrderSnapshotStateV2>(snapshot.Data);
if (state is null)
{
// Fall back to V1 and upgrade
var v1 = JsonSerializer.Deserialize<OrderSnapshotStateV1>(snapshot.Data);
state = new OrderSnapshotStateV2
{
OrderId = v1!.OrderId,
CustomerId = v1.CustomerId,
TotalAmount = v1.TotalAmount,
ShippingAddress = null // Default for new field
};
}
// Apply state
Id = state.OrderId;
CustomerId = state.CustomerId;
TotalAmount = state.TotalAmount;
ShippingAddress = state.ShippingAddress;
}
Declarative Snapshot Upgraders
For formal version migrations, use the SnapshotUpgrader<TFrom, TTo> base class and SnapshotVersionManager:
// Implement a typed upgrader
public class OrderSnapshotV1ToV2 : SnapshotUpgrader<OrderSnapshotStateV1, OrderSnapshotStateV2>
{
public OrderSnapshotV1ToV2(ISnapshotSerializer serializer) : base(serializer) { }
public override string AggregateType => "Order";
public override int FromVersion => 1;
public override int ToVersion => 2;
protected override OrderSnapshotStateV2 UpgradeSnapshot(OrderSnapshotStateV1 old)
=> new()
{
OrderId = old.OrderId,
CustomerId = old.CustomerId,
TotalAmount = old.TotalAmount,
ShippingAddress = null // Default for new field
};
}
// Register in DI
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder => builder
.AddSnapshotUpgrading(upgrading => upgrading
.RegisterUpgrader(new OrderSnapshotV1ToV2(serializer))
.SetCurrentVersion(2)
.EnableAutoUpgradeOnLoad())));
The SnapshotVersionManager uses BFS to find the shortest upgrade path between any two versions, supporting multi-hop migrations (e.g., v1 → v2 → v3). Schema version is stored in the snapshot's Metadata["SnapshotSchemaVersion"] key.
Performance Considerations
When to Use Snapshots
| Scenario | Recommendation |
|---|---|
| < 100 events | Don't use snapshots |
| 100-500 events | Optional, depends on event complexity |
| 500+ events | Strongly recommended |
| Long-running aggregates | Required |
Snapshot Size
Keep snapshot state types small (these are serialized into ISnapshot.Data):
// Good - only essential state
public record OrderSnapshotState
{
public required Guid OrderId { get; init; }
public required string CustomerId { get; init; }
public required OrderStatus Status { get; init; }
public required decimal TotalAmount { get; init; }
public required List<OrderLineState> Lines { get; init; }
}
// Bad - too much data
public record OrderSnapshotState
{
public required Guid OrderId { get; init; }
public Customer? Customer { get; init; } // Don't embed other aggregates
public List<Order>? RelatedOrders { get; init; } // Don't include relationships
public AuditLog? History { get; init; } // Don't duplicate event data
}
Note: Your state type is serialized into the
ISnapshot.Dataproperty (ReadOnlyMemory<byte>). TheISnapshotinterface is the envelope - you don't implement it directly for your state. TheSnapshot.Create()factory acceptsbyte[]via implicit conversion for convenience.
Testing with Snapshots
Unit Test: Snapshot Round-Trip
public class OrderSnapshotTests
{
[Fact]
public void Snapshot_RestoresState_Correctly()
{
// Arrange
var order = Order.Create(Guid.NewGuid(), "customer-1");
order.AddLine("product-1", 2, 10.00m);
order.AddLine("product-2", 1, 25.00m);
order.Submit();
// Act
var snapshot = order.CreateSnapshot();
var restored = Order.Create(order.Id, ""); // Create empty aggregate
restored.LoadFromSnapshot(snapshot);
// Assert
restored.Id.Should().Be(order.Id);
restored.CustomerId.Should().Be(order.CustomerId);
restored.Status.Should().Be(order.Status);
restored.TotalAmount.Should().Be(order.TotalAmount);
restored.Lines.Should().HaveCount(2);
}
}
Integration Test: Repository with Snapshots
For integration tests, use DI to wire up the full repository:
public class OrderRepositorySnapshotTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly IServiceProvider _services;
public OrderRepositorySnapshotTests(WebApplicationFactory<Program> factory)
{
_services = factory.Services;
}
[Fact]
public async Task Repository_UsesSnapshot_WhenAvailable()
{
// Arrange
using var scope = _services.CreateScope();
var repository = scope.ServiceProvider
.GetRequiredService<IEventSourcedRepository<Order, Guid>>();
var order = Order.Create(Guid.NewGuid(), "customer-1");
for (int i = 0; i < 150; i++)
order.AddLine($"product-{i}", 1, 10.00m);
await repository.SaveAsync(order, CancellationToken.None);
// Act - loading should use snapshot after threshold
var loaded = await repository.GetByIdAsync(order.Id, CancellationToken.None);
// Assert
loaded.Should().NotBeNull();
loaded!.Lines.Should().HaveCount(150);
}
}
Testing Setup with In-Memory Stores
// In your test fixture or Startup
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
builder.UseIntervalSnapshots(100);
builder.AddRepository<Order, Guid>();
}));
// Register in-memory stores for testing
services.AddSingleton<IEventStore, InMemoryEventStore>();
services.AddSingleton<ISnapshotStore, InMemorySnapshotStore>();
Next Steps
- Event Store — Understand event persistence
- Projections — Build read models
- Event Versioning — Handle schema evolution
See Also
- Repositories — How repositories integrate with the snapshot store during loading and saving
- Snapshot Setup — Step-by-step configuration guide for snapshot providers
- Caching — Complementary caching strategies for read-model performance
- Event Store Providers — Provider-specific snapshot store implementations