Skip to main content

Aggregates + invariants (the heart)

If you only learn one thing: your aggregate is where the truth lives.

The aggregate:

  • validates commands
  • enforces invariants
  • emits domain events
  • can rebuild itself from its event stream

The domain we'll model

Bank account rules:

  • you must open an account before using it
  • you can't deposit or withdraw after closing
  • you can't withdraw below zero

Events (facts)

public interface IDomainEvent;

public sealed record AccountOpened(Guid AccountId, string OwnerName) : IDomainEvent;
public sealed record MoneyDeposited(decimal Amount) : IDomainEvent;
public sealed record MoneyWithdrawn(decimal Amount) : IDomainEvent;
public sealed record AccountClosed(DateTimeOffset ClosedAt) : IDomainEvent;

Notes:

  • Events are immutable.
  • Use past tense names (Opened, Deposited, Withdrawn).
  • Keep them domain-shaped, not "database-shaped".

Aggregate skeleton

public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _uncommitted = new();

public Guid Id { get; protected set; }
public long Version { get; private set; } // confirmed event count

protected void Raise(IDomainEvent @event)
{
Apply(@event);
_uncommitted.Add(@event);
}

public IReadOnlyList<IDomainEvent> DequeueUncommittedEvents()
{
var events = _uncommitted.ToArray();
_uncommitted.Clear();
return events;
}

public void LoadFromHistory(IEnumerable<IDomainEvent> history)
{
foreach (var e in history)
{
Apply(e);
Version++;
}
}

protected abstract void Apply(IDomainEvent @event);
}

BankAccount aggregate (command methods)

public sealed class BankAccount : AggregateRoot
{
public string OwnerName { get; private set; } = "";
public decimal Balance { get; private set; }
public bool IsClosed { get; private set; }

public static BankAccount Open(Guid accountId, string ownerName)
{
if (accountId == Guid.Empty) throw new ArgumentException("AccountId is required.");
if (string.IsNullOrWhiteSpace(ownerName)) throw new ArgumentException("OwnerName is required.");

var acc = new BankAccount();
acc.Raise(new AccountOpened(accountId, ownerName.Trim()));
return acc;
}

public void Deposit(decimal amount)
{
EnsureOpen();
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "Deposit must be > 0.");
Raise(new MoneyDeposited(amount));
}

public void Withdraw(decimal amount)
{
EnsureOpen();
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "Withdraw must be > 0.");
if (Balance - amount < 0) throw new InvalidOperationException("Insufficient funds.");
Raise(new MoneyWithdrawn(amount));
}

public void Close(DateTimeOffset closedAt)
{
EnsureOpen();
Raise(new AccountClosed(closedAt));
}

private void EnsureOpen()
{
if (Id == Guid.Empty) throw new InvalidOperationException("Account is not opened.");
if (IsClosed) throw new InvalidOperationException("Account is closed.");
}

protected override void Apply(IDomainEvent @event)
{
switch (@event)
{
case AccountOpened e:
Id = e.AccountId;
OwnerName = e.OwnerName;
Balance = 0;
IsClosed = false;
break;
case MoneyDeposited e:
Balance += e.Amount;
break;
case MoneyWithdrawn e:
Balance -= e.Amount;
break;
case AccountClosed:
IsClosed = true;
break;
default:
throw new NotSupportedException($"Unknown event type: {@event.GetType().Name}");
}
}
}

Why Apply must be "boring"

Rule: Apply should be deterministic and have no side effects.

  • no DB calls
  • no HTTP calls
  • no "generate new Guid"
  • no "DateTimeOffset.UtcNow" inside Apply

Reason: you will replay history many times (rebuild state, rebuild projections, fix bugs).

Next

We have an aggregate. Now we need to treat events like long-lived contracts.

Next: Event design + schema