Когда обычного CRUD уже недостаточно: знакомство с CQRS и Event Sourcing
Представьте, что вы строите систему бронирования билетов. Всё просто, пока пользователей десять. Но когда их становится тысяча, а потом — миллион, начинаются проблемы: базы данных блокируются, запросы конфликтуют, а историю изменений восстановить невозможно. Знакомая ситуация?
В мире Go-разработки, где производительность и параллелизм — наше всё, два архитектурных паттерна помогают решить эти проблемы: CQRS (Command Query Responsibility Segregation) и Event Sourcing. Сегодня разберём их на практике — с кодом, примерами и реальными сценариями использования.
Что такое CQRS простыми словами?
CQRS — это разделение ответственности между командами (запись) и запросами (чтение). В классическом CRUD одна модель данных отвечает и за создание, и за чтение. В CQRS мы создаём две отдельные модели:
- Command model — отвечает за изменение состояния (создание, обновление, удаление)
- Query model — отвечает только за чтение данных
Это позволяет оптимизировать каждую сторону независимо: запись может быть строго валидированной, а чтение — денормализованным и быстрым. В Go это особенно эффективно благодаря горутинам и каналам.
Простой пример CQRS на Go
Давайте создадим базовую систему для управления заказами. Начнём с команд:
type CreateOrderCommand struct {
OrderID string
UserID string
ProductID string
Amount float64
}
type CommandHandler struct {
repo *OrderRepository
}
func (h *CommandHandler) HandleCreateOrder(ctx context.Context, cmd CreateOrderCommand) error {
order := Order{
ID: cmd.OrderID,
UserID: cmd.UserID,
ProductID: cmd.ProductID,
Amount: cmd.Amount,
Status: "pending",
CreatedAt: time.Now(),
}
return h.repo.Save(ctx, order)
}
Теперь создадим отдельную модель для чтения:
type OrderQuery struct {
repo *ReadRepository
}
type OrderView struct {
OrderID string
UserName string
ProductName string
Total float64
Status string
}
func (q *OrderQuery) GetOrderSummary(ctx context.Context, orderID string) (*OrderView, error) {
return q.repo.GetOrderView(ctx, orderID)
}
Обратите внимание: в OrderView уже есть денормализованные данные (имя пользователя, название продукта), которые собираются из разных источников. Это ускоряет чтение в десятки раз.
Event Sourcing: записываем не состояние, а события
Event Sourcing меняет подход к хранению данных: вместо того чтобы хранить текущее состояние объекта, мы храним последовательность событий, которые к этому состоянию привели. Это даёт суперсилу: полную историю изменений и возможность «отмотать» состояние назад.
Реализация Event Sourcing в Go
Начнём с базовых типов событий:
type Event struct {
ID string
AggregateID string
Type string
Data []byte
Timestamp time.Time
Version int
}
type EventStore interface {
Save(ctx context.Context, events []Event) error
Load(ctx context.Context, aggregateID string) ([]Event, error)
}
Теперь реализуем агрегат — объект, который восстанавливает своё состояние из событий:
type OrderAggregate struct {
ID string
Status string
Items []OrderItem
Version int
}
func (a *OrderAggregate) Apply(event Event) {
switch event.Type {
case "OrderCreated":
var data OrderCreatedData
json.Unmarshal(event.Data, &data)
a.ID = data.OrderID
a.Status = "created"
case "OrderConfirmed":
a.Status = "confirmed"
case "OrderShipped":
a.Status = "shipped"
}
a.Version++
}
func (a *OrderAggregate) HandleCommand(cmd interface{}) ([]Event, error) {
switch c := cmd.(type) {
case CreateOrderCommand:
data, _ := json.Marshal(OrderCreatedData{
OrderID: c.OrderID,
UserID: c.UserID,
})
return []Event{{
AggregateID: c.OrderID,
Type: "OrderCreated",
Data: data,
Version: a.Version + 1,
}}, nil
}
return nil, errors.New("unknown command")
}
Объединяем CQRS и Event Sourcing: практическая архитектура
В реальном проекте эти паттерны работают в связке:
- Команда поступает в Command Handler
- Handler загружает агрегат из Event Store
- Агрегат проверяет бизнес-правила и генерирует события
- События сохраняются в Event Store
- Event Bus публикует события
- Проекторы (Projectors) подписываются на события и обновляют Query Model
Создаём Event Bus для Go
type EventBus struct {
handlers map[string][]func(Event)
mu sync.RWMutex
}
func NewEventBus() *EventBus {
return &EventBus{
handlers: make(map[string][]func(Event)),
}
}
func (b *EventBus) Subscribe(eventType string, handler func(Event)) {
b.mu.Lock()
defer b.mu.Unlock()
b.handlers[eventType] = append(b.handlers[eventType], handler)
}
func (b *EventBus) Publish(ctx context.Context, event Event) {
b.mu.RLock()
handlers := b.handlers[event.Type]
b.mu.RUnlock()
for _, handler := range handlers {
go handler(event) // Асинхронная обработка
}
}
Проектор: обновляем Query Model
type OrderProjector struct {
readDB *sql.DB
}
func (p *OrderProjector) HandleOrderCreated(event Event) {
var data OrderCreatedData
json.Unmarshal(event.Data, &data)
p.readDB.Exec(
"INSERT INTO order_views (order_id, status, created_at) VALUES ($1, $2, $3)",
data.OrderID, "created", event.Timestamp,
)
}
func SetupProjectors(bus *EventBus, projector *OrderProjector) {
bus.Subscribe("OrderCreated", projector.HandleOrderCreated)
bus.Subscribe("OrderConfirmed", projector.HandleOrderConfirmed)
}
Когда использовать CQRS и Event Sourcing в Go?
Эти паттерны — не серебряная пуля. Вот сценарии, где они действительно нужны:
- Аудиты и комплаенс — когда нужно хранить полную историю изменений
- Сложная бизнес-логика — когда состояние зависит от последовательности событий
- Высокая нагрузка на чтение — когда нужно разделить read и write модели
- Микросервисы — для слабой связанности между сервисами
Но не используйте CQRS и Event Sourcing для простых CRUD-приложений — это добавит сложности без выгоды.
Лучшие практики для Go
1. Используйте интерфейсы для тестирования
type EventStore interface {
Save(ctx context.Context, events []Event) error
Load(ctx context.Context, aggregateID string) ([]Event, error)
}
type InMemoryEventStore