Введение: Когда CRUD перестаёт справляться
Представьте, что вы пишете систему для биржевых торгов. Каждую секунду — тысячи ордеров. Каждый ордер — это не просто "вставить запись". Это проверка баланса, пересчёт портфеля, аудит, уведомления. А теперь представьте, что вам нужно не только записать текущее состояние, но и восстановить, что произошло в 14:35:22.003 вчера. Обычный CRUD (Create, Read, Update, Delete) тут ломается. Добро пожаловать в мир CQRS и Event Sourcing на Java.
Эти два паттерна — не просто модные слова. Это инструменты, которые позволяют строить системы с высокой производительностью, полным аудитом и возможностью "перемотки времени". В этой статье мы разберём, как реализовать CQRS (Command Query Responsibility Segregation) и Event Sourcing в Java, используя современные фреймворки и чистый код. Без воды, с примерами.
Что такое CQRS простыми словами
CQRS — это разделение ответственности между командами (запись) и запросами (чтение). В классическом CRUD вы используете одну и ту же модель для чтения и записи. В CQRS вы создаёте две разные модели: одну для изменения данных (команды), другую для их извлечения (запросы).
Почему это нужно?
- Производительность: Чтение и запись масштабируются независимо. Можно иметь 10 реплик для чтения и 2 для записи.
- Безопасность: Команды могут проверять бизнес-правила, а запросы — просто отдавать данные.
- Гибкость: Для чтения можно использовать денормализованные представления, для записи — нормализованные.
Пример кода: Разделение команд и запросов
Допустим, у нас есть система управления заказами. Вместо того чтобы делать OrderService.save(order), мы разделяем:
// Команда — это намерение изменить состояние
public class CreateOrderCommand {
private final String orderId;
private final String customerId;
private final List<OrderItem> items;
// конструктор, геттеры...
}
// Обработчик команды
@Component
public class CreateOrderCommandHandler {
@Autowired
private OrderRepository orderRepository;
public void handle(CreateOrderCommand command) {
// Валидация бизнес-правил
if (command.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
Order order = new Order(command.getOrderId(), command.getCustomerId(), command.getItems());
orderRepository.save(order);
}
}
// Запрос — это получение данных
public class GetOrderQuery {
private final String orderId;
// конструктор, геттеры...
}
// Обработчик запроса
@Component
public class GetOrderQueryHandler {
@Autowired
private OrderReadRepository orderReadRepository;
public OrderDTO handle(GetOrderQuery query) {
// Прямое чтение из оптимизированной БД
return orderReadRepository.findById(query.getOrderId());
}
}
Обратите внимание: OrderRepository (запись) и OrderReadRepository (чтение) — это разные интерфейсы. Они могут указывать на разные базы данных или разные схемы.
Event Sourcing: Храним не состояние, а историю
Event Sourcing — это паттерн, при котором мы сохраняем не текущее состояние объекта, а последовательность событий, которые к этому состоянию привели. Вместо "Заказ №123: статус = DELIVERED" мы храним: "Событие 1: Заказ создан", "Событие 2: Товар добавлен", "Событие 3: Заказ оплачен", "Событие 4: Заказ доставлен".
Как это работает в Java
Каждое событие — это неизменяемый объект (immutable). Мы сохраняем их в Event Store (специализированная БД). Текущее состояние агрегата вычисляется путём воспроизведения (replaying) всех событий.
// Базовое событие
public interface DomainEvent {
String getAggregateId();
Instant getOccurredOn();
}
// Конкретное событие
public class OrderCreatedEvent implements DomainEvent {
private final String orderId;
private final String customerId;
private final Instant occurredOn;
// конструктор, геттеры...
}
// Агрегат — корень транзакционной согласованности
public class OrderAggregate {
private String orderId;
private String status;
private List<DomainEvent> uncommittedEvents = new ArrayList<>();
public OrderAggregate(String orderId, String customerId) {
apply(new OrderCreatedEvent(orderId, customerId, Instant.now()));
}
public void apply(OrderCreatedEvent event) {
this.orderId = event.getOrderId();
this.status = "CREATED";
uncommittedEvents.add(event);
}
public List<DomainEvent> getUncommittedEvents() {
return uncommittedEvents;
}
// Восстановление из истории
public OrderAggregate(List<DomainEvent> events) {
for (DomainEvent event : events) {
when(event);
}
}
private void when(DomainEvent event) {
if (event instanceof OrderCreatedEvent) {
apply((OrderCreatedEvent) event);
}
// другие обработчики...
}
}
Когда вы вызываете new OrderAggregate("123", "cust1"), создаётся событие OrderCreatedEvent. Оно сохраняется в Event Store. Чтобы получить текущее состояние, вы загружаете все события для этого агрегата и воспроизводите их.
Объединяем CQRS и Event Sourcing
Вместе эти паттерны работают как часы: CQRS разделяет каналы записи и чтения, а Event Sourcing предоставляет источник истины через события. Команды генерируют события, события сохраняются, а проекции (read models) подписываются на события и обновляют свои данные для чтения.
Архитектура в деталях
- Command Bus — принимает команды и направляет их обработчикам.
- Aggregate — проверяет бизнес-правила и генерирует события.
- Event Store — сохраняет события (например, PostgreSQL, MongoDB, EventStoreDB).
- Event Bus — публикует события для подписчиков.
- Projectors — слушают события и обновляют read models.
- Read Models — денормализованные таблицы для быстрых запросов.
Пример полного цикла с Axon Framework
Axon Framework — это популярный Java-фреймворк, который реализует CQRS и Event Sourcing "из коробки". Вот как выглядит типичный сценарий:
// 1. Команда
public class PlaceOrderCommand {
@TargetAggregateIdentifier
private final String orderId;
private final String productId;
private final int quantity;
// конструктор...
}
// 2. Агрегат с Axon
@Aggregate
public class OrderAggregate {
@AggregateIdentifier
private String orderId;
private boolean placed;
public OrderAggregate() {
// Required by Axon
}
@CommandHandler
public OrderAggregate(PlaceOrderCommand cmd) {
// бизнес-логика
if (cmd.getQuantity() <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
apply(new