Software Architecture/Engineering  

Event-Sourced Aggregates with Snapshot Optimization

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:

  • Full auditability

  • Ability to rebuild previous states

  • High scalability

  • Non-destructive writes

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:

  • Current state

  • List of uncommitted events

  • Logic to apply events

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 StoreSnapshot Store
Stores events onlyStores full aggregate state
Append-onlyPoint-in-time JSON
ImmutableMutable
High write scaleUsed 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

  1. Avoid very large aggregates

  2. Tune snapshot interval

  3. Use append-only table for events

  4. Offload projections using background workers

  5. Compress snapshot JSON

  6. Use row-level compression in SQL Server

  7. Use Redis or memory cache for hot aggregates

Recovery, Rebuild, and Replay

With event sourcing, you can:

  • Rebuild projections

  • Reconstruct historical states

  • Replay events into new read models

  • Verify consistency

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:

  • Perfect audit trail

  • Time-travel debugging

  • High performance

  • Low-latency reads

  • Full historical transparency

This is ideal for BFSI, healthcare, manufacturing, aviation, insurance, e-commerce and multi-tenant SaaS platforms.