Привет, Хабр! Я сегодня хочу разобрать одну из самых мощных, но часто неправильно понимаемых архитектурных концепций — CQRS. Если вы уже переросли уровень «просто писать CRUD» и задумываетесь о том, как строить системы, которые будут масштабироваться и оставаться производительными — эта статья для вас.

Почему CRUD иногда не работает

Давайте начнем с классики. Представьте, что вы делаете любой современный сервис — соцсеть, маркетплейс, трекер задач. У вас есть сущность Пользователь, Заказ, Пост. Стандартный подход:

// Типичный REST-контроллер
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderRepository.findById(id);
    }
    
    @PostMapping
    public Order createOrder(@RequestBody Order order) {
        return orderRepository.save(order);
    }
    
    @PutMapping("/{id}")
    public Order updateOrder(@PathVariable Long id, 
                            @RequestBody Order order) {
        order.setId(id);
        return orderRepository.save(order);
    }
}

Кажется, всё логично? Пока у вас 100 пользователей — да. Но давайте рассмотрим реальные проблемы, которые возникают на практике:

Проблема 1: Разные требования к чтению и записи

  • Запись (Command) должна быть консистентной, транзакционной, валидной

  • Чтение (Query) должно быть быстрым, можно кэшировать, можно денормализовать

В CRUD вы используете одну и ту же модель для всего. Это как пытаться готовить ужин и есть его с одной тарелки — неудобно и медленно.

Проблема 2: Блокировки и конкуренция

Представьте: 1000 пользователей одновременно смотрят свои заказы (SELECT), и 10 пользователей в этот момент создают новые заказы (INSERT/UPDATE). В PostgreSQL при определенных условиях чтения могут блокироваться на запись и наоборот.

Проблема 3: Сложные агрегации

Когда вам нужно показать дашборд с метриками «среднее время доставки», «конверсия по категориям», «динамика продаж», вы пишете монструозные SQL-запросы с 5-7 JOIN'ами. Они убивают производительность.

CQRS: Разделяй и властвуй

CQRS (Command Query Responsibility Segregation) — это принцип разделения модели на две:

  • Command модель — для изменения состояния

  • Query модель — для чтения данных

Реальный пример: система заказов

Давайте разберем на конкретном примере, как CQRS решает наши проблемы.

Шаг 1: Разделяем команды и запросы

Command стор (пишем):

// Command - создание заказа
public class CreateOrderCommand {
    private UUID orderId;
    private UUID customerId;
    private List<OrderItem> items;
    private Address shippingAddress;
    private PaymentMethod paymentMethod;
}

// Обработчик команды
@Service
@Transactional
public class CreateOrderHandler {
    
    public void handle(CreateOrderCommand command) {
        // Валидация бизнес-правил
        if (command.getItems().isEmpty()) {
            throw new ValidationException("Заказ не может быть пустым");
        }
        
        Customer customer = customerRepository
            .findById(command.getCustomerId())
            .orElseThrow(() -> new CustomerNotFoundException());
        
        // Проверка лимитов
        if (customer.hasTooManyPendingOrders()) {
            throw new BusinessRuleException("Слишком много активных заказов");
        }
        
        // Создаем агрегат
        Order order = new Order(
            command.getOrderId(),
            command.getCustomerId(),
            command.getItems(),
            OrderStatus.CREATED
        );
        
        // Сохраняем в базу команд
        orderRepository.save(order);
        
        // Публикуем событие
        eventPublisher.publish(new OrderCreatedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getTotalAmount()
        ));
    }
}

Query стор (читаем):

// Специализированная модель для чтения
@Entity
@Table(name = "order_summaries")
public class OrderSummary {
    @Id
    private UUID orderId;
    
    private UUID customerId;
    private String customerName; // Денормализовано!
    private BigDecimal totalAmount;
    private String status;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    // Никакой бизнес-логики, только геттеры
}

// Репозиторий для быстрого чтения
@Repository
public interface OrderSummaryRepository 
    extends JpaRepository<OrderSummary, UUID> {
    
    // Простые, быстрые запросы
    List<OrderSummary> findByCustomerId(UUID customerId);
    
    @Query("SELECT o FROM OrderSummary o WHERE " +
           "o.status = :status AND o.createdAt >= :since")
    List<OrderSummary> findRecentByStatus(
        @Param("status") String status,
        @Param("since") LocalDateTime since
    );
}

Шаг 2: Синхронизация данных

Как данные попадают из Command базы в Query базу? Есть несколько подходов:

1. Event Sourcing + Projections (самый чистый):

// Событие
public class OrderCreatedEvent {
    private UUID orderId;
    private UUID customerId;
    private List<OrderItem> items;
    private BigDecimal totalAmount;
}

// Projection (проекция)
@Component
public class OrderSummaryProjection {
    
    @EventHandler
    public void on(OrderCreatedEvent event) {
        // Читаем дополнительные данные
        Customer customer = customerRepository
            .findById(event.getCustomerId());
        
        // Создаем денормализованную запись
        OrderSummary summary = new OrderSummary();
        summary.setOrderId(event.getOrderId());
        summary.setCustomerId(event.getCustomerId());
        summary.setCustomerName(customer.getName()); // Денормализация!
        summary.setTotalAmount(event.getTotalAmount());
        summary.setStatus("CREATED");
        summary.setCreatedAt(LocalDateTime.now());
        
        orderSummaryRepository.save(summary);
    }
    
    @EventHandler
    public void on(OrderStatusChangedEvent event) {
        // Обновляем только нужное поле
        orderSummaryRepository.updateStatus(
            event.getOrderId(), 
            event.getNewStatus()
        );
    }
}

2. Change Data Capture (CDC) — проще для старта:

-- Используем PostgreSQL logical replication
CREATE PUBLICATION order_publication FOR TABLE orders;

-- Или используем Debezium для автоматического
-- отслеживания изменений и отправки в Kafka

3. Dual-write (самый простой, но менее надежный):

// В обработчике команды пишем в обе базы
public void handle(CreateOrderCommand command) {
    // 1. Пишем в command базу
    orderRepository.save(order);
    
    // 2. Сразу пишем в query базу
    orderSummaryRepository.save(createSummary(order));
    
    // Проблема: что если вторая запись упадет?
}

Шаг 3: Преимущества, которые мы получаем

  1. Масштабируемость чтения отдельно от записи

    • Query базу можно реплицировать сколько угодно

    • Можно использовать разные СУБД: PostgreSQL для команд, Elasticsearch для поиска, Cassandra для временных рядов

  2. Оптимизированные модели

-- Вместо сложного JOIN'а:
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items i ON o.id = i.order_id
WHERE o.status = 'PENDING'   AND c.country = 'RU'
-- Имеем готовую денормализованную таблицу:
SELECT * FROM order_summaries WHERE status = 'PENDING'   AND customer_country = 'RU'
-- В 10-100 раз быстрее!
  1. Разные команды разработки могут работать параллельно

    • Команда "оплаты" работает с Command стороной

    • Команда "аналитики" работает с Query стороной

    • Меньше конфликтов в коде

Паттерны, которые работают с CQRS

Event Sourcing: полный аудит системы

Если CQRS — это разделение, то Event Sourcing — это хранение. Вместо хранения текущего состояния, мы храним все события, которые к нему привели.

// Агрегат с Event Sourcing
public class OrderAggregate {
    private UUID id;
    private OrderState state;
    private List<DomainEvent> changes = new ArrayList<>();
    
    public void createOrder(UUID orderId, UUID customerId, 
                           List<OrderItem> items) {
        apply(new OrderCreatedEvent(orderId, customerId, items));
    }
    
    public void confirmPayment(UUID paymentId) {
        if (state.status != OrderStatus.CREATED) {
            throw new IllegalStateException("Заказ не в том статусе");
        }
        apply(new OrderPaidEvent(id, paymentId));
    }
    
    private void apply(DomainEvent event) {
        // 1. Обновляем состояние
        this.state = applyEvent(state, event);
        
        // 2. Сохраняем событие
        changes.add(event);
    }
    
    // Восстановление состояния из событий
    public static OrderAggregate recreateFromEvents(List<DomainEvent> events) {
        OrderAggregate aggregate = new OrderAggregate();
        for (DomainEvent event : events) {
            aggregate.state = aggregate.applyEvent(aggregate.state, event);
        }
        return aggregate;
    }
}

Saga для распределенных транзакций

В микросервисной архитектуре CQRS часто сочетается с Saga-паттерном:

// Saga для процесса "Оформление заказа"
@Component
public class OrderSaga {
    
    @StartSaga
    @SagaEventHandler(associationProperty = "orderId")
    public void handle(OrderCreatedEvent event) {
        // 1. Резервируем товары на складе
        commandGateway.send(new ReserveStockCommand(
            event.getOrderId(),
            event.getItems()
        ));
    }
    
    @SagaEventHandler(associationProperty = "orderId")
    public void handle(StockReservedEvent event) {
        // 2. Если товары зарезервированы, создаем платеж
        commandGateway.send(new CreatePaymentCommand(
            event.getOrderId(),
            event.getTotalAmount()
        ));
    }
    
    @SagaEventHandler(associationProperty = "orderId")
    public void handle(PaymentCompletedEvent event) {
        // 3. Если оплата прошла, подтверждаем заказ
        commandGateway.send(new ConfirmOrderCommand(
            event.getOrderId()
        ));
        
        // Завершаем сагу
        SagaLifecycle.end();
    }
}

Когда НЕ нужно использовать CQRS

CQRS — не серебряная пуля. Он добавляет сложности:

  1. Eventual Consistency: данные в Query стороне немного отстают (секунды, иногда минуты)

  2. Сложность отладки: теперь у вас два источника данных

  3. Overhead: для простых CRUD-приложений это избыточно

Используйте CQRS когда:

  • У вас высоконагруженное приложение

  • Требования к чтению и записи сильно различаются

  • Нужны сложные отчеты или аналитика

  • Команда готова к дополнительной сложности

Не используйте когда:

  • Делаете MVP или простой CRUD

  • В команде нет опыта работы с distributed systems

  • Строгая консистентность критически важна

Практический совет: начинайте постепенно

Не нужно переписывать всю систему сразу. Начните с самого проблемного места:

  1. Выделите один bounded context (например, "Отчеты" или "Поиск")

  2. Реализуйте CQRS только для него

  3. Используйте простой Dual-write для старта

  4. Добавьте Event Sourcing позже, если понадобится

// Постепенное внедрение - начинаем с одного модуля
@Configuration
@Profile("!cqrs")  // Старый подход для остальной системы
public class TraditionalConfig {
    @Bean
    public OrderService orderService() {
        return new TraditionalOrderService();
    }
}

@Configuration
@Profile("cqrs")  // Новый подход для модуля отчетов
public class CqrsConfig {
    @Bean
    public OrderQueryService orderQueryService() {
        return new CqrsOrderQueryService();
    }
}

Итог

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

  1. Производительность: оптимизированные модели под конкретные задачи

  2. Масштабируемость: независимое масштабирование чтения и записи

  3. Гибкость: возможность использовать разные технологии

  4. Поддержку: более чистый и понятный код

Но помните: каждая архитектурная решение — это trade-off. CQRS дает производительность и масштабируемость, но забирает простоту и немедленную консистентность.

С чего начать практику? Возьмите свой пет-проект, найдите в нем место, где чтение и запись конфликтуют (например, лента новостей + создание постов), и попробуйте разделить их. Сначала будет сложно, но когда вы увидите, как легко теперь масштабировать чтение отдельно от записи — вы поймете всю мощь этого подхода.

А вы использовали CQRS в своих проектах? С какими сложностями столкнулись? Делитесь опытом в комментариях!