CQRS и Event Sourcing в Go: архитектура для сложных систем

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Когда обычного 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: практическая архитектура

В реальном проекте эти паттерны работают в связке:

  1. Команда поступает в Command Handler
  2. Handler загружает агрегат из Event Store
  3. Агрегат проверяет бизнес-правила и генерирует события
  4. События сохраняются в Event Store
  5. Event Bus публикует события
  6. Проекторы (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                

Блоги

Рекомендаций по книгам