A Practical Guide for Building High-Scale, Audit-Friendly Systems
Event Sourcing is a powerful architectural pattern where the state of an entity is not stored directly. Instead, its state is rebuilt by replaying all the events that happened to it. As applications grow, replaying thousands of events for every request becomes slow. To solve this, we use Snapshot Optimization.
This article provides a full blueprint for designing event-sourced aggregates with snapshots, .NET implementation, and Angular UI integration, suitable for modern enterprise systems.
Introduction
Modern enterprise systems require:
Event Sourcing naturally provides this capability. Every change to an entity becomes an event, and the current state is a result of replaying all events.
But when you have thousands of events per entity, rebuilding state becomes expensive. This is where Snapshots come in.
What Is Event Sourcing?
Event Sourcing stores every state change as an event.
Example: Instead of updating a balance field:
Balance = 10000 → 12000
You store:
Deposited 2000
Withdrawn 500
Fee Applied (50)
Interest Applied (300)
Current balance is computed from events.
What Is an Aggregate?
An Aggregate is a consistency boundary.
It ensures business rules are applied correctly.
Examples:
Order aggregate
User aggregate
Policy aggregate
Loan account aggregate
Aggregates hold:
Why Event Sourcing Needs Snapshots
Replaying events is fine initially.
But for long-lived aggregates:
Thousands of events
Slow rehydration
More memory usage
High cold-start latency
Slower API responses
Snapshots store the latest computed state, so only recent events must be replayed.
Example:
Snapshot v100 → Replay events 101–120 only
This greatly improves performance.
Architecture Overview
┌────────────────────────┐
│ Angular UI │
│ (Commands + Queries) │
└─────────┬──────────────┘
│
▼
┌──────────────────────────────────────────┐
│ API Gateway / BFF │
└─────────┬────────────────────────────────┘
▼
┌────────────────────────┐
│ Aggregate Service │
│ (.NET) │
└──────┬─────────────────┘
│
▼
┌──────────────────────────────┬─────────────────────────────┐
│ Event Store (Append Only) │ Snapshot Store (SQL/Blob) │
└──────────────────────────────┴─────────────────────────────┘
Event Store vs SQL Store
| Event Store | Snapshot Store |
|---|
| Stores events only | Stores full aggregate state |
| Append-only | Point-in-time JSON |
| Immutable | Mutable |
| High write scale | Used rarely (read optimization) |
Workflow Diagram
User Action → Create Command → Validate Aggregate → Apply Changes → Generate Events → Persist Events → Update Snapshot (if needed)
Flowchart of Snapshot Loading
┌────────────────────────┐
│ Load Snapshot (if any) │
└─────────┬──────────────┘
│ Snapshot found?
┌─────────┴──────────┐
Yes No
▼ ▼
Load snapshot state Start from empty state
▼ ▼
Load events after snapshot version
▼
Apply events to rebuild current state
Designing the Aggregate
Example: Policy Aggregate
public class PolicyAggregate
{
public Guid Id { get; private set; }
public PolicyState State { get; private set; }
public int Version { get; private set; }
private readonly List<IDomainEvent> _changes = new();
public void Apply(IDomainEvent evt)
{
State = State.Apply(evt);
Version++;
_changes.Add(evt);
}
}
Event Versioning
Each event has:
EventId
AggregateId
EventType
EventVersion
CreatedOn
Payload (JSON)
This enables:
Backward compatibility
Schema evolution
Rolling deployments
Snapshot Strategies
1. Interval-Based Snapshots
Take snapshot every N events.
Example:
After every 100 events
2. Time-Based Snapshots
Take snapshots every X hours.
3. Heuristic Snapshots
Based on:
Event count
Size of payload
Business thresholds
4. Manual Snapshot Trigger
User presses "Optimize" or "Rebuild".
.NET Implementation
1. Event Storage
public async Task AppendEvents(Guid id, IEnumerable<IDomainEvent> events)
{
foreach (var evt in events)
{
await _db.InsertAsync("Events", new {
AggregateId = id,
Type = evt.GetType().Name,
Data = JsonSerializer.Serialize(evt),
Version = evt.Version
});
}
}
2. Snapshot Loading
public async Task<PolicyAggregate> Load(Guid id)
{
var snapshot = await _snapshotRepo.GetSnapshot(id);
PolicyAggregate aggregate = snapshot != null
? JsonSerializer.Deserialize<PolicyAggregate>(snapshot.Data)
: new PolicyAggregate();
var events = await _eventRepo.GetEvents(id, afterVersion: aggregate.Version);
foreach (var evt in events)
aggregate.Apply(evt);
return aggregate;
}
3. Snapshot Writing
public async Task SaveSnapshot(PolicyAggregate agg)
{
var data = JsonSerializer.Serialize(agg);
await _snapshotRepo.Upsert(new Snapshot {
AggregateId = agg.Id,
Version = agg.Version,
Data = data
});
}
Angular Integration
Angular sends commands to your API:
submitClaim() {
this.http.post('/api/policy/submit', this.model)
.subscribe(x => this.toast.success("Claim submitted"));
}
Queries retrieve projections:
this.http.get(`/api/policy/${id}`)
.subscribe(policy => this.policy = policy);
Angular does not deal with events.
It works with read models (CQRS style).
Performance Considerations
Avoid very large aggregates
Tune snapshot interval
Use append-only table for events
Offload projections using background workers
Compress snapshot JSON
Use row-level compression in SQL Server
Use Redis or memory cache for hot aggregates
Recovery, Rebuild, and Replay
With event sourcing, you can:
Rebuild example:
dotnet run --replay policy
Best Practices
Keep events small and meaningful
Events must be immutable
Aggregate should enforce invariants
Use metadata: TraceId, UserId, IP
Keep snapshot lightweight
Store snapshot’s version
Always replay only events after snapshot
Ensure backward compatibility for events
Conclusion
Event Sourcing with Snapshot Optimization provides:
This is ideal for BFSI, healthcare, manufacturing, aviation, insurance, e-commerce and multi-tenant SaaS platforms.