Del 3 av 10

AggregateRoot i C# — mönstret från grunden

Vi bygger en återanvändbar AggregateRoot<TId>-basklass, ett konkret Account-aggregat med invarianter, och förbereder oss för Event Sourcing genom att låta aggregatet bygga sitt tillstånd uteslutande från sina egna events.

Lektion 3 — V2 mån. Idag är aggregatet huvudpersonen. Tester driver designen.

Vad gör ett aggregat?

Tre saker, varken mer eller mindre:

  1. Skyddar invarianter — regler som alltid måste gälla (saldot får inte gå minus, ett konto kan inte stängas två gånger).
  2. Beslutar vad som händer — varje publik metod är en command handler som returnerar zero, ett eller flera events.
  3. Bygger sitt tillstånd från historik — i ES-stil äger aggregatet inte sitt nuvarande tillstånd som primärkälla; tillståndet är en projektion av eventströmmen.

Basklassen: AggregateRoot<TId>

public abstract class AggregateRoot<TId> where TId : notnull
{
    private readonly List<IDomainEvent> _pending = new();

    public TId Id { get; protected set; } = default!;

    /// <summary>Antal events som redan persisterats. 0 = nytt aggregat.</summary>
    public int Version { get; private set; }

    /// <summary>Events som väntar på att skrivas till event store.</summary>
    public IReadOnlyList<IDomainEvent> PendingEvents => _pending;

    /// <summary>Anropas av handlers när en ny händelse uppstår.</summary>
    protected void Raise(IDomainEvent @event)
    {
        Apply(@event);          // uppdatera tillståndet direkt
        _pending.Add(@event);   // markera för persistens
    }

    /// <summary>Anropas av repositoryt när vi laddar historik.</summary>
    public void LoadFromHistory(IEnumerable<IDomainEvent> events)
    {
        foreach (var e in events)
        {
            Apply(e);
            Version++;
        }
    }

    public void MarkEventsAsCommitted()
    {
        Version += _pending.Count;
        _pending.Clear();
    }

    /// <summary>Subklasser routar till rätt Apply-metod här.</summary>
    protected abstract void Apply(IDomainEvent @event);
}
Två sätt att routa Apply Antingen en stor switch (enkel, snabb, tydlig) eller via reflektion (dynamic-dispatch eller källgenererad). Vi börjar med switch och håller det enkelt.

Account-aggregatet

public sealed class Account : AggregateRoot<AccountId>
{
    public Money Balance { get; private set; } = Money.Zero("SEK");
    public bool IsClosed { get; private set; }

    // Privata för att tvinga skapande via factory eller LoadFromHistory.
    private Account() { }

    public static Account Open(AccountId id, string currency)
    {
        var account = new Account();
        account.Raise(new AccountOpened(
            EventId: Guid.NewGuid(),
            OccurredAt: DateTimeOffset.UtcNow,
            Account: id,
            Currency: currency));
        return account;
    }

    public void Deposit(Money amount, string reference)
    {
        if (IsClosed)
            throw new InvalidOperationException("Account is closed.");
        if (amount.Amount <= 0)
            throw new ArgumentException("Amount must be positive.", nameof(amount));
        if (amount.Currency != Balance.Currency)
            throw new InvalidOperationException("Currency mismatch.");

        Raise(new MoneyDeposited(
            Guid.NewGuid(), DateTimeOffset.UtcNow, Id, amount, reference));
    }

    public void Withdraw(Money amount, string reference)
    {
        if (IsClosed) throw new InvalidOperationException("Account is closed.");
        if (amount.Amount <= 0) throw new ArgumentException("Amount must be positive.");
        if (amount.Currency != Balance.Currency) throw new InvalidOperationException("Currency mismatch.");
        if (Balance.Subtract(amount).Amount < 0)
            throw new InvalidOperationException("Insufficient funds.");

        Raise(new MoneyWithdrawn(
            Guid.NewGuid(), DateTimeOffset.UtcNow, Id, amount, reference));
    }

    public void Close()
    {
        if (IsClosed) return;                     // idempotent
        if (Balance.Amount != 0)
            throw new InvalidOperationException("Cannot close account with non-zero balance.");

        Raise(new AccountClosed(Guid.NewGuid(), DateTimeOffset.UtcNow, Id));
    }

    protected override void Apply(IDomainEvent @event)
    {
        switch (@event)
        {
            case AccountOpened e:
                Id = e.Account;
                Balance = Money.Zero(e.Currency);
                break;
            case MoneyDeposited e:
                Balance = Balance.Add(e.Amount);
                break;
            case MoneyWithdrawn e:
                Balance = Balance.Subtract(e.Amount);
                break;
            case AccountClosed:
                IsClosed = true;
                break;
        }
    }
}

Den viktiga principen: kommando ≠ tillståndsändring

Notera att de publika metoderna (Deposit, Withdraw, Close) inte ändrar fält direkt. De validerar, kallar Raise, och låter Apply uppdatera tillståndet. Detta ger två fördelar:

"Men var sparas det någonstans?" Helt rätt fråga — och svaret är: inte i aggregatet. account.Deposit(...) ändrar bara objektet i minnet och lägger ett event i listan _pending. Ingenting skrivs till disk förrän någon annan komponent — repositoryt — hämtar de pendande eventen och skriver dem till event store. Det är hela poängen: aggregatet vet inte att en databas existerar.

Hela kedjan i tre steg

För en deposit-request mot API:t händer detta:

  1. Ladda — repositoryt skapar ett tomt Account-objekt och kör LoadFromHistory(events) med alla tidigare events. Saldot räknas fram av Apply.
  2. Bestämma — vi kallar account.Deposit(...). Den validerar, kallar Raise(new MoneyDeposited(...)). Eventet hamnar i _pending. Saldo-fältet uppdateras direkt via Apply (så objektet är konsistent ifall vi vill göra fler operationer).
  3. Spara — repositoryt läser account.PendingEvents och skriver dem till event store. Sedan kallas MarkEventsAsCommitted() som tömmer listan.

Repositoryt (som vi bygger på riktigt i lektion 5 och 6) ser i grova drag ut så här:

public class EventSourcedRepository<TAggregate, TId>
    where TAggregate : AggregateRoot<TId>, new()
    where TId : notnull
{
    private readonly IEventStore _store;
    public EventSourcedRepository(IEventStore store) => _store = store;

    public async Task<TAggregate?> LoadAsync(TId id)
    {
        var history = await _store.ReadStreamAsync(id.ToString());
        if (history.Count == 0) return null;

        var aggregate = new TAggregate();
        aggregate.LoadFromHistory(history);  // steg 1: bygg upp tillståndet
        return aggregate;
    }

    public async Task SaveAsync(TAggregate aggregate)
    {
        if (aggregate.PendingEvents.Count == 0) return;

        await _store.AppendToStreamAsync(            // steg 3: persistera
            streamId: aggregate.Id.ToString(),
            expectedVersion: aggregate.Version,
            events: aggregate.PendingEvents);

        aggregate.MarkEventsAsCommitted();
    }
}

Och en command handler (kommer i lektion 4) knyter ihop allt:

public async Task Handle(DepositMoney cmd)
{
    var account = await _repo.LoadAsync(cmd.AccountId)
                  ?? throw new NotFoundException();

    account.Deposit(new Money(cmd.Amount, cmd.Currency), cmd.Reference);  // steg 2

    await _repo.SaveAsync(account);
}
Sammanfattat Aggregatet är "domänkärnan" — det vet bara regler och tillstånd. Repositoryt är "infrastruktursnittet" — det vet om databasen. Den här uppdelningen är det som gör aggregatet enhetstestbart utan mocks (testerna ovan kallar metoderna direkt utan repository), och som gör att vi kan byta event store (in-memory → SqlStreamStore → annat) utan att ändra en rad domänkod.

Tester som beskriver beteendet

public class AccountTests
{
    private static readonly AccountId Id = new(Guid.NewGuid());

    [Fact]
    public void Open_raises_AccountOpened()
    {
        var account = Account.Open(Id, "SEK");

        account.PendingEvents.Should().ContainSingle()
            .Which.Should().BeOfType<AccountOpened>();
        account.Balance.Should().Be(Money.Zero("SEK"));
    }

    [Fact]
    public void Deposit_increases_balance_and_raises_event()
    {
        var account = Account.Open(Id, "SEK");
        account.MarkEventsAsCommitted();

        account.Deposit(new Money(500, "SEK"), "salary");

        account.Balance.Should().Be(new Money(500, "SEK"));
        account.PendingEvents.Should().ContainSingle()
            .Which.Should().BeOfType<MoneyDeposited>();
    }

    [Fact]
    public void Withdraw_more_than_balance_throws()
    {
        var account = Account.Open(Id, "SEK");

        var act = () => account.Withdraw(new Money(100, "SEK"), "x");

        act.Should().Throw<InvalidOperationException>()
            .WithMessage("Insufficient funds.");
    }

    [Fact]
    public void Closed_account_rejects_deposits()
    {
        var account = Account.Open(Id, "SEK");
        account.Close();

        var act = () => account.Deposit(new Money(1, "SEK"), "x");

        act.Should().Throw<InvalidOperationException>()
            .WithMessage("Account is closed.");
    }

    [Fact]
    public void LoadFromHistory_rebuilds_state_without_raising_new_events()
    {
        var account = new Account();  // tom — som det laddas av repositoryt
        var history = new IDomainEvent[]
        {
            new AccountOpened(Guid.NewGuid(), DateTimeOffset.UtcNow, Id, "SEK"),
            new MoneyDeposited(Guid.NewGuid(), DateTimeOffset.UtcNow, Id, new(1000, "SEK"), "init"),
            new MoneyWithdrawn(Guid.NewGuid(), DateTimeOffset.UtcNow, Id, new(300, "SEK"), "rent"),
        };

        account.LoadFromHistory(history);

        account.Balance.Should().Be(new Money(700, "SEK"));
        account.Version.Should().Be(3);
        account.PendingEvents.Should().BeEmpty();
    }
}
Lägg märke till Inga mocks. Inga repositories. Aggregatet är en POCO — det går att testa snabbt och utan I/O. Detta är en av de stora fördelarna med att lyfta domänlogik ur infrastrukturen.

Child entities — när aggregatet inte är platt

Ett biljettbokningsaggregat (Concert) innehåller många Seat-entiteter. Roten äger dem, omvärlden ser dem bara genom roten.

public sealed class Concert : AggregateRoot<ConcertId>
{
    private readonly Dictionary<SeatId, Seat> _seats = new();
    public IReadOnlyDictionary<SeatId, Seat> Seats => _seats;
    public string Title { get; private set; } = "";

    public void ReserveSeat(SeatId seatId, CustomerId customer)
    {
        if (!_seats.TryGetValue(seatId, out var seat))
            throw new InvalidOperationException("Unknown seat.");
        if (seat.Status != SeatStatus.Available)
            throw new InvalidOperationException("Seat is not available.");

        Raise(new SeatReserved(
            Guid.NewGuid(), DateTimeOffset.UtcNow, Id, seatId, customer));
    }

    protected override void Apply(IDomainEvent @event)
    {
        switch (@event)
        {
            case ConcertScheduled e:
                Id = e.Concert;
                Title = e.Title;
                foreach (var s in e.Seats) _seats[s] = new Seat(s, SeatStatus.Available);
                break;
            case SeatReserved e:
                _seats[e.Seat] = _seats[e.Seat] with { Status = SeatStatus.Reserved };
                break;
        }
    }
}

internal sealed record Seat(SeatId Id, SeatStatus Status);
internal enum SeatStatus { Available, Reserved, Sold }

Hur stort ska ett aggregat vara?

Vernons regel: "Design small aggregates". Konkret:

Vanlig nybörjarfälla Att göra Concert till ett gigantiskt aggregat som innehåller alla Booking-instanser för konserten. Det fungerar tills två kunder bokar samtidigt — då blir det concurrency-helvete. Bättre: Concert håller bara vilka säten som är lediga, varje Booking är ett eget aggregat med egen ström.

Sammanfattning

Referenser

Elektroniska resurser

Böcker

Övningar

Lös övningarna självständigt. Det finns inget facit — lärandet sker i processen.

  1. Bygg basklassen Implementera AggregateRoot<TId> enligt exemplet, men byt switch-baserad Apply mot en reflektionsbaserad lookup (cacha MethodInfo per typ). Skriv ett enkelt benchmark — hur stor skillnaden blir i tid per event.
  2. Implementera Account Bygg Account-aggregatet och skriv minst 8 enhetstester som täcker: open, deposit (gilltigt + ogiltigt belopp + fel valuta), withdraw (gilltigt + insufficient funds), close (gilltigt + non-zero balance), och rehydrering från events.
  3. Concert med säten Bygg Concert-aggregatet med 50 säten och en ReserveSeat-metod. Skriv ett test som visar att man inte kan reservera samma säte två gånger. Skriv ett test som visar att LoadFromHistory återställer både rotens och alla sätens tillstånd korrekt.
  4. För litet eller för stort? Givet domänen "kursanmälningar" — diskutera (skriv en sida) om Course ska vara ett aggregat med Enrollment-children, eller om Enrollment ska vara ett eget aggregat. Vilka invarianter krävs? Vilken concurrency-risk uppstår vid varje val?

Soloprojektor

Projekt 1 — Återanvändbart aggregatbibliotek Skapa ett klassbibliotek YourName.Aggregates med AggregateRoot<TId>, IDomainEvent och tillhörande hjälpklasser. Skapa även en in-memory event store för testning. Packetera som lokalt NuGet-paket. Demonstrera med ett ShoppingCart-aggregat (events: CartCreated, ItemAdded, ItemRemoved, CartCheckedOut). 15+ tester.
Projekt 2 — Källgenererad Apply (fördjupning) Skriv en Roslyn Source Generator som automatiskt genererar switch-statementet i Apply baserat på metoder med signaturen private void When(SpecificEvent e). Visa hur det förbättrar läsbarheten i ett komplext aggregat (minst 6 eventtyper).

← Föregående: DDD-grunder Nästa: CQRS & Commands →