Oskar Dudycz

Pragmatic about programming

Jak w Event Sourcing odbudować stan agregatu?

2021-04-26 oskar dudyczEvent Sourcing

cover

Cześć!

Chciałbym dzisiaj wrócić trochę do podstaw Event Sourcing. Zdałem sobie niedawno sprawę, że omawiam często bardziej zaawansowane tematy. Czasem przydaje się cofnąć o krok i doszlifować podstawy.

W Event Sourcing stan aplikacji przechowywany jest w zdarzeniach. Gdy dodajemy zdarzenie, jest one umieszczone na koniec struktury zwanej append-only log (przeczytaj więcej w moim wpisie “What if I told you that Relational Databases are in fact Event Stores?”). Stąd też i nazwa. Zdarzenia są źródłem prawdy. Ma to sporo zalet, choćby:

  • historię zmian w naszym systemie,
  • łatwiejszą diagnostykę,
  • bliskość z biznesem, bo nasze struktury w kodzie odpowiadają faktom biznesowym.

Nie musi to jednak powodować automatycznej rewolucji w naszym kodzie. Możemy w dalszym ciągu posługiwać się agregatami/encjami. W Event Sourcing zdarzenia są logicznie połączone w strumienie. Strumienie są to uporządkowane sekwencje zdarzeń. Jeden strumień są to wszystkie zdarzenia, które zaszły dla danego obiektu biznesowego, np. WystawionoFakturęProforma, PotwierdzonoFakturę, WysłanoFakturę.

Aby zamienić taką serię zdarzeń, do jednej spłaszczonej encji zwykle wykonuje się następujące kroki):

  1. Pobierz wszystkie zdarzenia dla danego strumienia. Wybieramy je na podstawie identyfikatora strumienia (czyli rekordu).
  2. Posortuj je według kolejności, w jakiej zaszły.
  3. Utwórz domyślną, pustą encję (np. przy pomocy domyślnego konstruktora).
  4. Zaaplikuj każde ze zdarzeń po kolei na encji.

Pierwsze trzy punkty są, myślę oczywiste, co to jednak znaczy zaaplikować zdarzenie? Sposoby są dwa:

  • Marten przyjmuje konwencję, że każde zdarzenie powinno mieć metodę Apply, która jako jedyny parametr przyjmuje obiekt zdarzenia. Dzięki temu wedłig konwencji jest w stanie wywołać te metody. Najbardziej banalne to użycie refleksji czy innej magii do tego.
  • Inną spotykaną często konwencją jest użycie metody When. Przyjmuje ona jako parametr obiekt zdarzenia, następnie wewnątrz przy pomocy “pattern matching” możemy sobie ustalić, jakie to jest zdarzenie i jak uaktualnić stan naszej encji. Trzeba nieco więcej samemu napisać, ale jest mniej magii, plus jest to rozwiązanie niezależne od frameworka.

W C# mogłoby to wyglądać następująco:

public record Person(
    string Name,
    string Address
);

public record InvoiceInitiated(
    double Amount,
    string Number,
    Person IssuedTo,
    DateTime InitiatedAt
);

public record InvoiceIssued(
    string IssuedBy,
    DateTime IssuedAt
);

public enum InvoiceSendMethod
{
    Email,
    Post
}

public record InvoiceSent(
    InvoiceSendMethod SentVia,
    DateTime SentAt
);

public enum InvoiceStatus
{
    Initiated = 1,
    Issued = 2,
    Sent = 3
}

public class Invoice
{
    public string Id { get;set; }
    public double Amount { get; private set; }
    public string Number { get; private set; }

    public InvoiceStatus Status { get; private set; }

    public Person IssuedTo { get; private set; }
    public DateTime InitiatedAt { get; private set; }

    public string IssuedBy { get; private set; }
    public DateTime IssuedAt { get; private set; }

    public InvoiceSendMethod SentVia { get; private set; }
    public DateTime SentAt { get; private set; }

    public void When(object @event)
    {
        switch (@event)
        {
            case InvoiceInitiated invoiceInitiated:
                Apply(invoiceInitiated);
                break;
            case InvoiceIssued invoiceIssued:
                Apply(invoiceIssued);
                break;
            case InvoiceSent invoiceSent:
                Apply(invoiceSent);
                break;
        }
    }

    private void Apply(InvoiceInitiated @event)
    {
        Id = @event.Number;
        Amount = @event.Amount;
        Number = @event.Number;
        IssuedTo = @event.IssuedTo;
        InitiatedAt = @event.InitiatedAt;
        Status = InvoiceStatus.Initiated;
    }

    private void Apply(InvoiceIssued @event)
    {
        IssuedBy = @event.IssuedBy;
        IssuedAt = @event.IssuedAt;
        Status = InvoiceStatus.Issued;
    }

    private void Apply(InvoiceSent @event)
    {
        SentVia = @event.SentVia;
        SentAt = @event.SentAt;
        Status = InvoiceStatus.Sent;
    }
}

Użycie z kolei:

var invoiceInitiated = new InvoiceInitiated(
    34.12,
    "INV/2021/11/01",
    new Person("Oscar the Grouch", "123 Sesame Street"),
    DateTime.UtcNow
);
var invoiceIssued = new InvoiceIssued(
    "Cookie Monster",
    DateTime.UtcNow
);
var invoiceSent = new InvoiceSent(
    InvoiceSendMethod.Email,
    DateTime.UtcNow
);

// 1,2. Get all events and sort them in the order of appearance
var events = new object[] {invoiceInitiated, invoiceIssued, invoiceSent};

// 3. Construct empty Invoice object
var invoice = new Invoice();

// 4. Apply each event on the entity.
foreach (var @event in events)
{
    invoice.When(@event);
}

Jeśli ktoś lubi można też dodać metodę bazową:

public abstract class Aggregate<T>
{
    public T Id { get; protected set; }
        
    public abstract void When(object @event);
}

Więcej do zobaczenia w: https://github.com/oskardudycz/EventSourcing.NetCore/blob/main/Core.Tests/AggregateWithWhenTests.cs.

Jest to prosty wzorzec, ale mający dużo możliwości. Pozwala łatwe debuggowanie, napisanie testów jednostkowych oraz lepszej kontroli nad tym co się dzieje.

Oczywiście jest to podejście mocno imperatywne. Pracuję aktualnie nad przykładami w NodeJS, gdzie będę pokazywał bardziej funkcyjne podejście. Jeśli interesuje Cię zobaczenie prac w toku to proszę: https://github.com/oskardudycz/EventSourcing.NodeJS/pull/9/files.

Komentarze chętnie przyjmę!

Pozdrawiam!

Oskar

P.S. Jak co tydzień polecam nowe Architecture Weekly: https://github.com/oskardudycz/ArchitectureWeekly#26th-april-2021

  • © Oskar Dudycz 2019-2020