Die meisten Spring-Boot-Anwendungen speichern nur den aktuellen Zustand ihrer Daten in einer relationalen Datenbank. Reicht das? Nicht immer. Wenn du nachvollziehen musst, wie ein Zustand entstanden ist — etwa bei Buchungen, Audit-Trails oder regulatorischen Anforderungen — brauchst du Event Sourcing. Und wenn deine Lese- und Schreibmuster stark voneinander abweichen, hilft CQRS (Command Query Responsibility Segregation).
Das Axon Framework vereint beide Patterns unter einem Dach und integriert sich nahtlos in Spring Boot. Dieser Artikel zeigt dir die praktische Umsetzung.
Was Axon 4.11 leistet
Axon (aktuell 4.11.2, mit 5.1 als neueste Major Version) modelliert eine Anwendung rund um drei Kernkonzepte:
- Aggregate — verarbeitet Commands, trifft Entscheidungen und publiziert Events
- Event Store — speichert alle Events als unveränderliche Quelle der Wahrheit
- Projection — hört auf Events und baut daraus optimierte Query-Modelle
Statt repository.save(order) zu rufen, sendest du einen Command, und Axon kümmert sich um Routing, Event-Persistierung und asynchrone Projektion.
Setup mit Spring Boot
Maven-Dependencies:
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
</dependency>
<em><!-- Optional: Axon Server für verteilte Setups --></em>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-server-connector</artifactId>
</dependency>
Code-Sprache: HTML, XML (xml)
Ohne Axon Server verwendet Axon einen In-Memory-Event-Store. Für lokale Entwicklung perfekt — in Produktion wechselst du auf Axon Server oder eine JPA/JDBC-Event-Store-Konfiguration.
Domain: Ein Bestellprozess
Für unser Beispiel modellieren wir einen vereinfachten Bestellvorgang: Eine Bestellung wird erstellt, ein Betrag zugewiesen und schließlich bestätigt.
Commands und Events
import org.axonframework.modelling.command.TargetAggregateIdentifier;
public class CreateOrderCommand {
@TargetAggregateIdentifier
private final String orderId;
private final String product;
private final int amount;
public CreateOrderCommand(String orderId, String product, int amount) {
this.orderId = orderId;
this.product = product;
this.amount = amount;
}
public String getOrderId() { return orderId; }
public String getProduct() { return product; }
public int getAmount() { return amount; }
}
public class ConfirmOrderCommand {
@TargetAggregateIdentifier
private final String orderId;
public ConfirmOrderCommand(String orderId) {
this.orderId = orderId;
}
public String getOrderId() { return orderId; }
}
Code-Sprache: PHP (php)
public class OrderCreatedEvent {
private final String orderId;
private final String product;
private final int amount;
public OrderCreatedEvent(String orderId, String product, int amount) {
this.orderId = orderId;
this.product = product;
this.amount = amount;
}
public String getOrderId() { return orderId; }
public String getProduct() { return product; }
public int getAmount() { return amount; }
}
public class OrderConfirmedEvent {
private final String orderId;
public OrderConfirmedEvent(String orderId) {
this.orderId = orderId;
}
public String getOrderId() { return orderId; }
}
Code-Sprache: PHP (php)
Der Aggregate
import org.axonframework.commandhandling.CommandHandler;
import org.axonframework.eventsourcing.EventSourcingHandler;
import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.spring.stereotype.Aggregate;
import static org.axonframework.modelling.command.AggregateLifecycle.apply;
@Aggregate
public class Order {
@AggregateIdentifier
private String orderId;
private String product;
private int amount;
private boolean confirmed;
@CommandHandler
public Order(CreateOrderCommand cmd) {
if (cmd.getAmount() <= 0) {
throw new IllegalArgumentException("Betrag muss positiv sein");
}
apply(new OrderCreatedEvent(
cmd.getOrderId(), cmd.getProduct(), cmd.getAmount()));
}
@EventSourcingHandler
public void on(OrderCreatedEvent event) {
this.orderId = event.getOrderId();
this.product = event.getProduct();
this.amount = event.getAmount();
}
@CommandHandler
public void handle(ConfirmOrderCommand cmd) {
if (confirmed) {
throw new IllegalStateException("Bereits bestätigt");
}
apply(new OrderConfirmedEvent(cmd.getOrderId()));
}
@EventSourcingHandler
public void on(OrderConfirmedEvent event) {
this.confirmed = true;
}
protected Order() {}
}
Code-Sprache: JavaScript (javascript)
Wichtig: @AggregateIdentifier ist Pflicht — Axon braucht es, um Commands zum richtigen Aggregate zu routen. Der parameterlose Konstruktor wird von Axon beim Replay benötigt.
Der Zustand eines Aggregates wird niemals direkt gesetzt. @CommandHandler-Methoden validieren und publizieren Events; @EventSourcingHandler-Methoden reagieren auf Events und setzen Felder. Beim Laden aus dem Event Store spielt Axon alle historischen Events erneut ab (Event Replay).
Die Projection (Query-Modell)
import org.axonframework.eventhandling.EventHandler;
import org.axonframework.queryhandling.QueryHandler;
import org.springframework.stereotype.Component;
import java.util.*;
@Component
public class OrderProjection {
private final Map<String, OrderSummary> orders = new ConcurrentHashMap<>();
@EventHandler
public void on(OrderCreatedEvent event) {
orders.put(event.getOrderId(), new OrderSummary(
event.getOrderId(), event.getProduct(),
event.getAmount(), false));
}
@EventHandler
public void on(OrderConfirmedEvent event) {
OrderSummary existing = orders.get(event.getOrderId());
if (existing != null) {
orders.put(event.getOrderId(), new OrderSummary(
existing.orderId(), existing.product(),
existing.amount(), true));
}
}
@QueryHandler
public OrderSummary handle(FindOrderQuery query) {
return orders.get(query.getOrderId());
}
@QueryHandler
public List<OrderSummary> handle(FindAllOrdersQuery query) {
return List.copyOf(orders.values());
}
}
public record OrderSummary(
String orderId, String product, int amount, boolean confirmed) {}
public class FindOrderQuery {
private final String orderId;
public FindOrderQuery(String orderId) { this.orderId = orderId; }
public String getOrderId() { return orderId; }
}
public class FindAllOrdersQuery {}
Code-Sprache: PHP (php)
Projections sind Spring-@Components. @EventHandler-Methoden werden asynchron aufgerufen, nachdem der Aggregate seine Events committed hat — Query-Modelle sind also eventually consistent.
REST-Controller
import org.axonframework.commandhandling.gateway.CommandGateway;
import org.axonframework.queryhandling.QueryGateway;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final CommandGateway commandGateway;
private final QueryGateway queryGateway;
public OrderController(CommandGateway commandGateway,
QueryGateway queryGateway) {
this.commandGateway = commandGateway;
this.queryGateway = queryGateway;
}
@PostMapping
public CompletableFuture<String> create(@RequestBody Map<String, Object> body) {
String orderId = UUID.randomUUID().toString();
return commandGateway.send(new CreateOrderCommand(
orderId,
(String) body.get("product"),
(int) body.get("amount")));
}
@PostMapping("/{orderId}/confirm")
public CompletableFuture<Void> confirm(@PathVariable String orderId) {
return commandGateway.send(new ConfirmOrderCommand(orderId));
}
@GetMapping("/{orderId}")
public CompletableFuture<OrderSummary> get(@PathVariable String orderId) {
return queryGateway.query(
new FindOrderQuery(orderId), OrderSummary.class);
}
@GetMapping
public CompletableFuture<List<OrderSummary>> getAll() {
return queryGateway.query(
new FindAllOrdersQuery(),
new org.axonframework.messaging.responsetypes
.MultipleInstancesResponseType<>(OrderSummary.class));
}
}
Code-Sprache: JavaScript (javascript)
CommandGateway.send() und QueryGateway.query() geben CompletableFuture zurück — der Aufrufer entscheidet selbst über synchrones oder asynchrones Verhalten.
Axon Server vs. Embedded Event Store
Axon Server (Docker-Image axoniq/axonserver:latest oder eine spezifische Version wie axoniq/axonserver:2024.1) bietet den vollen Funktionsumfang: Event Store, Command/Query Routing über mehrere Instanzen hinweg und ein Management-Dashboard. Für den Einstieg genügt der embedded Store — die Anwendung läuft direkt nach dem Booten.
Wann lohnt sich Axon?
Event Sourcing mit Axon ist kein Allheilmittel. Es glänzt in Szenarien mit:
- Audit-Anforderungen (Finanzen, Buchhaltung) — jeder Zustandsübergang ist ein Event
- Komplexen Geschäftslogiken (Sagas, Workflows) — Axon’s Saga-Support orchestriert langlaufende Prozesse
- CQRS (Lese-/Schreib-Trennung) — wenn deine Queries andere Datenstrukturen brauchen als Commands
Für einfache CRUD-Apps ist klassisches Spring Data JPA die bessere Wahl.
Fazit
Axon Framework 4.11 macht Event Sourcing und CQRS in Spring Boot zugänglich. Mit klaren Annotations (@Aggregate, @CommandHandler, @EventHandler, @QueryHandler) entsteht eine natürliche Trennung von Commands, Events und Queries — wartbar, testbar und auditierbar. Der Wechsel vom In-Memory-Store zu Axon Server ist fließend und erfordert keine Code-Änderungen. Ab Version 4.13 ist JDK 17 erforderlich; Axon Framework 5.x setzt JDK 21 voraus.
Wer mehr über die Historie seiner Daten wissen will: Axon ist dafür das richtige Werkzeug.