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:

Current balance is computed from events.

What Is an Aggregate?

An Aggregate is a consistency boundary.
It ensures business rules are applied correctly.

Examples:

Aggregates hold:

Why Event Sourcing Needs Snapshots

Replaying events is fine initially.
But for long-lived aggregates:

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:

This enables:

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:

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 example:

dotnet run --replay policy

Best Practices

Conclusion

Event Sourcing with Snapshot Optimization provides:

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