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ń.
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 Listevents = 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:
- 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, Listevents, 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 Listitems; 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; }
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 Listitems; // 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 OptionalgetLatest(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):
- 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 Listhandle(FindOrdersByCustomerQuery query) { return orderSummaryRepository.findByCustomerId(query.getCustomerId()); } }
Wyzwania Event Sourcing
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
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)
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ń.
NIE! Zdarzenia są immutable. Jeśli coś poszło nie tak, dodajesz kompensujące zdarzenie (np. OrderCancelled). To fundamentalna zasada Event Sourcing.
Given-When-Then pattern: Given (lista zdarzeń) When (wykonaj komendę) Then (oczekuj nowych zdarzeń). Testy są deterministyczne i łatwe do pisania.
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.
EventStore (Greg Young), Apache Kafka (jako log), Axon Server, lub własna implementacja na PostgreSQL/MySQL. Dla początkujących polecam Axon Framework.
Stopniowo! Zacznij od jednego bounded context. Stwórz „InitialStateCreated” event z aktualnym stanem. Nowe zmiany jako zdarzenia. Nie migruj wszystkiego naraz.
Crypto-shredding (usuń klucz, dane nieczytelne), event transformation (anonymizacja), lub przechowuj dane osobowe osobno i linkuj przez ID.
Przydatne zasoby
- Martin Fowler – Event Sourcing
- EventStore Official Documentation
- Axon Framework GitHub
- Event Sourcing with Kafka
- Azure Architecture – Event Sourcing Pattern
🚀 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!