Почему 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 лет, Москва", а последовательность:
- "Пользователь создан: Иван"
- "Пользователь сменил возраст на 25"
- "Пользователь переехал в Москву"
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 отлично ложится на разделение сервисов
Избегайте