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

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

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

Начать курс

Почему CQRS и Event Sourcing — это не "для избранных"?

Когда мы слышим словосочетания "CQRS" или "Event Sourcing", многие разработчики на JavaScript представляют себе что-то из мира enterprise-архитектуры на Java или C#. Сложные схемы, бесконечные события, странные хранилища... Но что, если я скажу вам, что эти паттерны можно (и нужно!) использовать в обычных Node.js приложениях?

В этой статье мы разберемся, как CQRS и Event Sourcing помогают решать реальные проблемы: конфликты данных, сложное логирование, масштабирование и отладку. И всё это — на чистом JavaScript, без магии и сложных фреймворков.

Что такое CQRS (Command Query Responsibility Segregation)?

Представьте, что ваше приложение — это ресторан. Официанты (запросы) и повара (команды) работают в одном помещении. Рано или поздно они начнут мешать друг другу. CQRS предлагает разделить эти зоны: официанты работают в зале (чтение), а повара — на кухне (запись).

CQRS — это принцип разделения операций на два канала:

  • Команды (Commands) — изменяют состояние системы (Create, Update, Delete)
  • Запросы (Queries) — только читают данные (Read)

В классическом REST API мы обычно используем один и тот же объект для чтения и записи. Например, UserController содержит методы GET /users/:id и POST /users. CQRS предлагает разнести их в разные слои.

Простой пример CQRS на JavaScript

// Команда (изменение состояния)
class CreateUserCommand {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
}

class UserCommandHandler {
    async handle(command) {
        // Валидация, бизнес-логика, сохранение
        return await db.users.insert({
            name: command.name,
            email: command.email,
            created_at: new Date()
        });
    }
}

// Запрос (только чтение)
class GetUserQuery {
    constructor(userId) {
        this.userId = userId;
    }
}

class UserQueryHandler {
    async handle(query) {
        // Только чтение, никаких изменений!
        return await db.users.findOne({ _id: query.userId });
    }
}

Кажется, что это усложняет код? Да, на первый взгляд. Но когда ваше приложение вырастет до 50+ эндпоинтов, разделение команд и запросов спасет вас от:

  • Случайного изменения данных в GET-запросах
  • Проблем с кэшированием (запросы легко кэшировать, команды — нет)
  • Конфликтов при параллельной работе нескольких разработчиков

Event Sourcing: запоминаем каждое действие

Теперь представьте, что вместо хранения текущего состояния пользователя вы храните все события, которые с ним произошли. Не "Иван, 25 лет, Москва", а последовательность:

  1. "Пользователь создан: Иван"
  2. "Пользователь сменил возраст на 25"
  3. "Пользователь переехал в Москву"

Event Sourcing — это паттерн, где каждое изменение состояния сохраняется как неизменяемое событие. Текущее состояние вычисляется путем "воспроизведения" всех событий.

Реализация Event Store на JavaScript

class EventStore {
    constructor() {
        this.events = [];
    }

    append(aggregateId, event) {
        this.events.push({
            aggregateId,
            event,
            timestamp: new Date()
        });
    }

    getEvents(aggregateId) {
        return this.events.filter(e => e.aggregateId === aggregateId);
    }
}

// Восстановление состояния из событий
function rebuildState(events) {
    return events.reduce((state, event) => {
        switch(event.event.type) {
            case 'USER_CREATED':
                return { ...state, name: event.event.name, age: 0 };
            case 'AGE_CHANGED':
                return { ...state, age: event.event.newAge };
            case 'CITY_CHANGED':
                return { ...state, city: event.event.newCity };
            default:
                return state;
        }
    }, {});
}

// Использование
const store = new EventStore();
store.append('user-123', { type: 'USER_CREATED', name: 'Иван' });
store.append('user-123', { type: 'AGE_CHANGED', newAge: 25 });
store.append('user-123', { type: 'CITY_CHANGED', newCity: 'Москва' });

const currentState = rebuildState(store.getEvents('user-123'));
console.log(currentState); // { name: 'Иван', age: 25, city: 'Москва' }

Объединяем CQRS и Event Sourcing: симбиоз идей

Эти два паттерна работают вместе как швейцарские часы. CQRS разделяет команды и запросы, а Event Sourcing обеспечивает надежное хранение изменений. Вот как это выглядит в реальном приложении:

// 1. Команда
class AddProductToCartCommand {
    constructor(userId, productId, quantity) {
        this.userId = userId;
        this.productId = productId;
        this.quantity = quantity;
    }
}

// 2. Обработчик команды
class CartCommandHandler {
    constructor(eventStore) {
        this.eventStore = eventStore;
    }

    async handle(command) {
        // Валидация
        if (command.quantity <= 0) {
            throw new Error('Quantity must be positive');
        }

        // Создаем событие
        const event = {
            type: 'PRODUCT_ADDED_TO_CART',
            productId: command.productId,
            quantity: command.quantity,
            timestamp: new Date()
        };

        // Сохраняем событие
        await this.eventStore.append(command.userId, event);
        
        // Опционально: обновляем read-модель
        await this.updateReadModel(command.userId);
    }

    async updateReadModel(userId) {
        const events = await this.eventStore.getEvents(userId);
        const currentCart = this.rebuildCart(events);
        // Сохраняем в быструю БД для чтения (например, Redis)
        await redis.set(`cart:${userId}`, JSON.stringify(currentCart));
    }

    rebuildCart(events) {
        // Восстанавливаем корзину из событий
        return events.reduce((cart, event) => {
            if (event.type === 'PRODUCT_ADDED_TO_CART') {
                cart.items.push({
                    productId: event.productId,
                    quantity: event.quantity
                });
            }
            return cart;
        }, { items: [] });
    }
}

// 3. Запрос (читает из подготовленной модели)
class CartQueryHandler {
    async handle(userId) {
        const cartData = await redis.get(`cart:${userId}`);
        return JSON.parse(cartData);
    }
}

Когда стоит использовать CQRS + Event Sourcing?

Не каждый проект нуждается в такой архитектуре. Вот честные критерии:

  • Высокая нагрузка на запись — если у вас 1000 запросов в секунду на создание заказов
  • Необходимость аудита — каждое действие должно быть залогировано (финансы, медицина)
  • Сложная бизнес-логика — когда нужно откатывать изменения или воспроизводить историю
  • Микросервисная архитектура — CQRS отлично ложится на разделение сервисов

Избегайте

Блоги

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