Event Sourcing – nowe podejście do danych

TL;DR: Event Sourcing to wzorzec architektoniczny, w którym zapisujemy wszystkie zmiany stanu aplikacji jako sekwencję zdarzeń. Zamiast przechowywać aktualny stan, przechowujemy pełną historię tego, co się wydarzyło. Daje to audit log, time travel debugging, łatwiejsze skalowanie i naturalne wsparcie dla CQRS.

Tradycyjne aplikacje CRUD tracą cenną informację – historię zmian. Event Sourcing zmienia to podejście, traktując zdarzenia jako źródło prawdy o stanie systemu. To nie tylko techniczna ciekawostka – to fundamentalna zmiana w sposobie myślenia o danych.

Dlaczego Event Sourcing jest ważny?

W świecie, gdzie compliance, audyt i analiza zachowań użytkowników są kluczowe, przechowywanie tylko aktualnego stanu to za mało. Event Sourcing odpowiada na potrzeby biznesowe: pełna historia transakcji, możliwość cofnięcia się w czasie, naturalna integracja z systemami analitycznymi.

Co się nauczysz:

  • Fundamentalne koncepcje Event Sourcing
  • Różnice między state-based a event-based storage
  • Implementacja Event Store w praktyce
  • Projekcje i odtwarzanie stanu ze zdarzeń
  • Integracja z CQRS i Domain-Driven Design
  • Wyzwania i sposoby ich rozwiązywania
  • Kiedy stosować Event Sourcing (i kiedy nie)

Wymagania wstępne:

  • Doświadczenie w projektowaniu systemów
  • Znajomość wzorców architektonicznych
  • Podstawy Domain-Driven Design
  • Zrozumienie problemów związanych z distributed systems

Czym jest Event Sourcing?

Event Sourcing to wzorzec, w którym zamiast zapisywać bieżący stan encji, zapisujemy wszystkie zdarzenia które doprowadziły do tego stanu. Stan jest odtwarzany poprzez odtworzenie (replay) wszystkich zdarzeń.

Event – niemutowalny fakt biznesowy który już się wydarzył. Np. „OrderPlaced”, „PaymentReceived”, „ItemShipped”.

Porównanie z tradycyjnym podejściem

// Tradycyjne podejście - CRUD
public class Order {
    private String id;
    private OrderStatus status;
    private BigDecimal totalAmount;
    
    public void ship() {
        this.status = OrderStatus.SHIPPED;
        // zapisujemy aktualny stan do bazy
        orderRepository.save(this);
    }
}

// Event Sourcing
public class Order {
    private String id;
    private List events = new ArrayList<>();
    
    public void ship() {
        // Tworzymy zdarzenie zamiast mutować stan
        OrderShipped event = new OrderShipped(id, Instant.now());
        events.add(event);
        eventStore.append(id, event);
    }
    
    // Stan odtwarzamy ze zdarzeń
    public static Order fromEvents(List events) {
        Order order = new Order();
        events.forEach(order::apply);
        return order;
    }
}

Anatomia Event Store

Event Store to specjalizowana baza danych do przechowywania zdarzeń. Kluczowe cechy:

Cechy Event Store:

  • Append-only – zdarzenia są tylko dodawane, nigdy modyfikowane
  • Immutable events – zdarzenia są niezmienne
  • Ordered – zdarzenia mają ścisłą kolejność
  • Partitioned by aggregate – zdarzenia grupowane per agregat

Implementacja prostego Event Store

public interface EventStore {
    void append(String aggregateId, List events, int expectedVersion);
    List getEvents(String aggregateId);
    List getEvents(String aggregateId, int fromVersion);
}

@Component
public class JdbcEventStore implements EventStore {
    
    private final JdbcTemplate jdbc;
    
    @Transactional
    public void append(String aggregateId, List events, int expectedVersion) {
        // Optimistic locking check
        int currentVersion = getCurrentVersion(aggregateId);
        if (currentVersion != expectedVersion) {
            throw new ConcurrentModificationException(
                "Expected version " + expectedVersion + " but was " + currentVersion);
        }
        
        int version = currentVersion;
        for (DomainEvent event : events) {
            jdbc.update(
                "INSERT INTO events (aggregate_id, version, event_type, event_data, created_at) " +
                "VALUES (?, ?, ?, ?, ?)",
                aggregateId,
                ++version,
                event.getClass().getSimpleName(),
                serialize(event),
                Instant.now()
            );
        }
    }
    
    public List getEvents(String aggregateId) {
        return jdbc.query(
            "SELECT * FROM events WHERE aggregate_id = ? ORDER BY version",
            new Object[]{aggregateId},
            (rs, rowNum) -> deserialize(
                rs.getString("event_type"),
                rs.getString("event_data")
            )
        );
    }
}

Modelowanie zdarzeń

Zdarzenia powinny reprezentować fakty biznesowe, nie techniczne operacje:

// Base event class
public abstract class DomainEvent {
    private final String aggregateId;
    private final Instant occurredAt;
    private final int version;
    
    protected DomainEvent(String aggregateId, Instant occurredAt) {
        this.aggregateId = aggregateId;
        this.occurredAt = occurredAt;
    }
}

// Konkretne zdarzenia biznesowe
public class OrderPlaced extends DomainEvent {
    private final String customerId;
    private final List items;
    private final BigDecimal totalAmount;
    
    // konstruktor, gettery...
}

public class PaymentReceived extends DomainEvent {
    private final String paymentMethod;
    private final BigDecimal amount;
    private final String transactionId;
}

public class OrderShipped extends DomainEvent {
    private final String trackingNumber;
    private final String carrier;
    private final LocalDate estimatedDelivery;
}
Pro tip: Nazywaj zdarzenia w czasie przeszłym (OrderPlaced, nie PlaceOrder). To fakty które już się wydarzyły!

Odtwarzanie stanu – Event Replay

Stan agregatu odtwarzamy aplikując zdarzenia w kolejności:

public class Order {
    private String id;
    private OrderStatus status;
    private BigDecimal totalAmount;
    private String customerId;
    private List items;
    
    // Event Sourcing handler methods
    @EventHandler
    public void on(OrderPlaced event) {
        this.id = event.getAggregateId();
        this.customerId = event.getCustomerId();
        this.items = new ArrayList<>(event.getItems());
        this.totalAmount = event.getTotalAmount();
        this.status = OrderStatus.PENDING;
    }
    
    @EventHandler
    public void on(PaymentReceived event) {
        this.status = OrderStatus.PAID;
    }
    
    @EventHandler
    public void on(OrderShipped event) {
        this.status = OrderStatus.SHIPPED;
    }
    
    // Replay all events to reconstruct state
    public static Order fromHistory(List history) {
        Order order = new Order();
        history.forEach(event -> {
            if (event instanceof OrderPlaced) {
                order.on((OrderPlaced) event);
            } else if (event instanceof PaymentReceived) {
                order.on((PaymentReceived) event);
            } else if (event instanceof OrderShipped) {
                order.on((OrderShipped) event);
            }
        });
        return order;
    }
}

Snapshots – optymalizacja wydajności

Przy długiej historii zdarzeń, odtwarzanie może być wolne. Snapshots to zapisany stan agregatu w określonym momencie:

public class SnapshotStore {
    
    public void save(String aggregateId, Object snapshot, int version) {
        jdbc.update(
            "INSERT INTO snapshots (aggregate_id, version, data, created_at) " +
            "VALUES (?, ?, ?, ?)",
            aggregateId, version, serialize(snapshot), Instant.now()
        );
    }
    
    public Optional getLatest(String aggregateId) {
        // Pobierz najnowszy snapshot
        return jdbc.query(
            "SELECT * FROM snapshots WHERE aggregate_id = ? " +
            "ORDER BY version DESC LIMIT 1",
            new Object[]{aggregateId},
            (rs, rowNum) -> new Snapshot(
                rs.getInt("version"),
                deserialize(rs.getString("data"))
            )
        ).stream().findFirst();
    }
}

// Użycie w repository
public Order findById(String orderId) {
    Optional snapshot = snapshotStore.getLatest(orderId);
    
    if (snapshot.isPresent()) {
        // Zacznij od snapshotu
        Order order = (Order) snapshot.get().getData();
        // Doładuj tylko zdarzenia po snapshocie
        List events = eventStore.getEvents(orderId, snapshot.get().getVersion());
        events.forEach(order::apply);
        return order;
    } else {
        // Pełny replay
        List events = eventStore.getEvents(orderId);
        return Order.fromHistory(events);
    }
}

Projekcje – Read Models

Projekcje to widoki danych zoptymalizowane pod kątem odczytu, budowane na podstawie zdarzeń:

@Component
public class OrderSummaryProjection {
    
    @EventHandler
    public void on(OrderPlaced event) {
        OrderSummary summary = new OrderSummary();
        summary.setOrderId(event.getAggregateId());
        summary.setCustomerId(event.getCustomerId());
        summary.setTotalAmount(event.getTotalAmount());
        summary.setStatus("PENDING");
        summary.setCreatedAt(event.getOccurredAt());
        
        orderSummaryRepository.save(summary);
    }
    
    @EventHandler
    public void on(PaymentReceived event) {
        orderSummaryRepository.updateStatus(event.getAggregateId(), "PAID");
    }
    
    @EventHandler
    public void on(OrderShipped event) {
        orderSummaryRepository.updateStatus(event.getAggregateId(), "SHIPPED");
        orderSummaryRepository.updateTracking(
            event.getAggregateId(), 
            event.getTrackingNumber()
        );
    }
}

// Projekcja dla raportów
@Component
public class DailySalesProjection {
    
    @EventHandler
    public void on(OrderPlaced event) {
        LocalDate date = event.getOccurredAt().atZone(ZoneId.systemDefault()).toLocalDate();
        
        DailySales sales = dailySalesRepository.findByDate(date)
            .orElse(new DailySales(date));
            
        sales.incrementOrderCount();
        sales.addToTotal(event.getTotalAmount());
        
        dailySalesRepository.save(sales);
    }
}

Event Sourcing + CQRS

Event Sourcing naturalnie łączy się z CQRS (Command Query Responsibility Segregation):

Synergia ES + CQRS:

  • Write model używa Event Sourcing
  • Read models to projekcje budowane ze zdarzeń
  • Możliwość wielu read models dla różnych potrzeb
  • Eventual consistency między write i read
// Command Handler - write side
@Component
public class OrderCommandHandler {
    
    @CommandHandler
    public void handle(PlaceOrderCommand command) {
        Order order = new Order(command.getOrderId());
        order.place(command.getCustomerId(), command.getItems());
        
        eventStore.append(order.getId(), order.getUncommittedEvents(), 0);
    }
    
    @CommandHandler
    public void handle(ShipOrderCommand command) {
        Order order = orderRepository.findById(command.getOrderId());
        order.ship(command.getTrackingNumber(), command.getCarrier());
        
        eventStore.append(order.getId(), order.getUncommittedEvents(), order.getVersion());
    }
}

// Query Handler - read side
@Component
public class OrderQueryHandler {
    
    @QueryHandler
    public OrderSummary handle(FindOrderQuery query) {
        // Czytamy z projekcji, nie z event store
        return orderSummaryRepository.findById(query.getOrderId())
            .orElseThrow(() -> new OrderNotFoundException(query.getOrderId()));
    }
    
    @QueryHandler
    public List handle(FindOrdersByCustomerQuery query) {
        return orderSummaryRepository.findByCustomerId(query.getCustomerId());
    }
}

Wyzwania Event Sourcing

Uwaga: Event Sourcing nie jest silver bullet. Ma swoje wyzwania które musisz zrozumieć przed wdrożeniem.

1. Ewolucja schematu zdarzeń

// Wersjonowanie zdarzeń
public class OrderPlacedV1 extends DomainEvent {
    private String customerId;
    private BigDecimal totalAmount;
}

public class OrderPlacedV2 extends DomainEvent {
    private String customerId;
    private BigDecimal totalAmount;
    private String couponCode; // nowe pole
}

// Upcasting - konwersja starych zdarzeń
public class OrderPlacedUpcaster {
    public OrderPlacedV2 upcast(OrderPlacedV1 oldEvent) {
        return new OrderPlacedV2(
            oldEvent.getAggregateId(),
            oldEvent.getCustomerId(),
            oldEvent.getTotalAmount(),
            null // brak kuponu w starej wersji
        );
    }
}

2. GDPR i usuwanie danych

// Crypto-shredding approach
public class EncryptedEvent extends DomainEvent {
    private String encryptedData;
    private String encryptionKeyId;
    
    // Przy usunięciu danych użytkownika, usuwamy klucz
    // Zdarzenia pozostają ale są nieczytelne
}

// Event transformation
public class GDPREventTransformer {
    public DomainEvent anonymize(DomainEvent event, String userId) {
        if (event instanceof OrderPlaced) {
            OrderPlaced original = (OrderPlaced) event;
            return new OrderPlaced(
                original.getAggregateId(),
                "ANONYMIZED_USER", // zamiast prawdziwego ID
                original.getItems(),
                original.getTotalAmount()
            );
        }
        return event;
    }
}

3. Eventual Consistency

Pułapka: Projekcje mogą być opóźnione względem write model. Musisz projektować UI z myślą o eventual consistency!

Kiedy stosować Event Sourcing?

✅ Dobre przypadki użycia:

  • Systemy finansowe – pełna historia transakcji
  • Systemy audytowe – compliance, regulacje
  • Collaborative editing – śledzenie zmian dokumentów
  • Gaming – replay meczów, undo/redo
  • Systemy z complex business logic – temporal queries

❌ Kiedy unikać:

  • Proste CRUD – overhead większy niż korzyści
  • Systemy z dużym churn rate – ciągłe usuwanie danych
  • Brak doświadczenia w zespole – krzywa uczenia się
  • Systemy real-time – eventual consistency problem

Często zadawane pytania (FAQ)

Jak duża może być historia zdarzeń?

Teoretycznie nieograniczona. W praktyce używamy snapshotów co N zdarzeń (np. co 100). Niektóre systemy archiwizują stare zdarzenia. Event Store może obsłużyć miliardy zdarzeń.

Czy mogę zmieniać/usuwać zdarzenia?

NIE! Zdarzenia są immutable. Jeśli coś poszło nie tak, dodajesz kompensujące zdarzenie (np. OrderCancelled). To fundamentalna zasada Event Sourcing.

Jak testować Event Sourcing?

Given-When-Then pattern: Given (lista zdarzeń) When (wykonaj komendę) Then (oczekuj nowych zdarzeń). Testy są deterministyczne i łatwe do pisania.

Co z wydajnością odczytu?

Używaj projekcji! Read models są zoptymalizowane pod kątem zapytań. Możesz mieć wiele projekcji dla różnych przypadków użycia. CQRS + ES to potężne połączenie.

Jaki Event Store wybrać?

EventStore (Greg Young), Apache Kafka (jako log), Axon Server, lub własna implementacja na PostgreSQL/MySQL. Dla początkujących polecam Axon Framework.

Jak migrować istniejący system do ES?

Stopniowo! Zacznij od jednego bounded context. Stwórz „InitialStateCreated” event z aktualnym stanem. Nowe zmiany jako zdarzenia. Nie migruj wszystkiego naraz.

Co z GDPR i prawem do bycia zapomnianym?

Crypto-shredding (usuń klucz, dane nieczytelne), event transformation (anonymizacja), lub przechowuj dane osobowe osobno i linkuj przez ID.

Przydatne zasoby

🚀 Zadanie dla Ciebie

Zaimplementuj prosty system koszyka zakupowego z Event Sourcing:

  • Zdarzenia: CartCreated, ItemAdded, ItemRemoved, CartCheckedOut
  • Implementacja Event Store (może być in-memory na początek)
  • Odtwarzanie stanu koszyka ze zdarzeń
  • Projekcja z podsumowaniem koszyka
  • Snapshot co 10 zdarzeń
  • Testy jednostkowe w stylu Given-When-Then

Porównaj złożoność z tradycyjnym CRUD. Kiedy Event Sourcing daje przewagę?

Event Sourcing to potężne narzędzie, ale wymaga zmiany sposobu myślenia o danych. Zamiast pytać „jaki jest stan?”, pytamy „co się wydarzyło?”. Ta zmiana perspektywy otwiera nowe możliwości w projektowaniu systemów. Jakie są Twoje doświadczenia z Event Sourcing? Podziel się w komentarzach!

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry