← назад к разделу · уровень 2 из 3 · предыдущий: уровень 1

На первом уровне бизнес-операции растворены в сервис-классах с десятками методов. Через полгода непонятно, что сервис умеет, проверки расходятся между вызовами, метрики и аудит прикручиваются вручную каждый раз заново.

Уровень 2 вводит одно правило: каждая бизнес-операция — отдельный UseCase + Handler. Этого достаточно, чтобы не утонуть в сервис-классах и получить единый список того, что сервис умеет.

Одна операция — два класса

Раньше сервис выглядел примерно так:

class OrderService {
    Order create(...) { ... }
    Order findById(...) { ... }
    void cancel(...) { ... }
    List<Order> list(...) { ... }
    // ещё пять методов...
}

Такой класс сложно тестировать изолированно, метрики не понять, что к чему относится, а ошибки в одном методе влияют на соседние.

На Уровне 2 каждая операция становится отдельной парой классов:

  • UseCase — структура с входными данными операции (что мы хотим сделать).
  • Handler — логика выполнения: проверки, обращения к БД, ответ.
// Входные данные операции
record CreateOrderUseCase(String customerId, List<String> items)
    implements UseCaseCommand<CreatedOrderResult> {}

// Логика
@Component
class CreateOrderUseCaseHandler
    implements UseCaseHandler<CreateOrderUseCase, CreatedOrderResult> {

    @Override
    @Transactional
    public CreatedOrderResult handle(CreateOrderUseCase uc) {
        // проверки, запись в БД, возврат результата
    }
}

Создание заказа — одна пара. Получение заказа по идентификатору — другая. Не «один OrderService с пятью методами», а пять отдельных пар.

Контроллер отдаёт операцию в диспетчер

Контроллер не знает о конкретном handler-е. Он разбирает HTTP-запрос, собирает UseCase-объект и передаёт в диспетчер — тот сам находит нужный handler по типу:

@RestController
class OrderController {
    private final UseCaseDispatcher dispatcher;

    @PostMapping("/orders")
    ResponseEntity<CreatedOrderResult> create(@RequestBody CreateOrderRequest req) {
        var result = dispatcher.dispatch(new CreateOrderUseCase(req.customerId(), req.items()));
        return ResponseEntity.ok(result);
    }
}

Бизнес-проверки и обращения к базе данных живут внутри handler, нигде больше. Контроллер остаётся тонким: принял → передал → завернул ответ.

Метрики встроены автоматически

Одно из главных преимуществ уровня 2 — библиотека автоматически фиксирует метрики для каждого UseCase: сколько раз вызван, сколько упал, сколько времени занял. Метки включают имя операции, её тип (команда или запрос), статус результата.

Не нужно добавлять метрики руками в каждый метод — они появляются автоматически для любого нового handler-а.

Тесты на handler без инфраструктуры

Handler — обычный класс с зависимостями. Его можно протестировать без запуска Spring, без HTTP:

class CreateOrderHandlerTest {
    CreateOrderUseCaseHandler handler = new CreateOrderUseCaseHandler(
        new InMemoryOrderRepository()
    );

    @Test
    void createsOrder() {
        var result = handler.handle(new CreateOrderUseCase("cust-1", List.of("item-a")));
        assertThat(result.orderId()).isNotNull();
    }
}

Это быстрее, надёжнее и проще, чем тестировать через контроллер.

CQRS — опция этого уровня

CQRS (разделение операций чтения и записи) — не отдельный уровень зрелости, а опция Уровня 2. Включается, когда чтение и запись начинают мешать друг другу: длинные запросы замедляют систему, запросам нужен агрессивный кэш, а командам — строгие транзакции.

Явное разделение по типу. Команды реализуют маркер UseCaseCommand — они меняют состояние. Запросы реализуют UseCaseQuery — только читают. Это видно в типе и проверяется при разборе кода.

record CancelOrderUseCase(String orderId)
    implements UseCaseCommand<Void> {} // команда — меняет состояние

record GetOrderUseCase(String orderId)
    implements UseCaseQuery<OrderView> {}   // запрос — только читает

Read Model — отдельная модель для чтения. Вместо того чтобы читать из той же таблицы, что и команды, запросы идут к оптимизированному представлению: материализованное view, денормализованная таблица, кэш. Это позволяет настроить чтение независимо от записи.

Команды возвращают минимум данных — идентификатор, краткий результат или ничего. За полными данными клиент делает отдельный запрос. Иначе смысл разделения теряется.

Согласованность с задержкой. Read Model обновляется асинхронно — через события, материализованные view или периодический пересчёт. Запрос может вернуть слегка устаревшие данные. Это нужно явно зафиксировать в поведении системы.

Что не нужно делать на этом уровне

Уровень 2 намеренно прост. Не нужно:

  • заводить агрегаты, value objects и доменные события — это Уровень 3;
  • строить порты и адаптеры — тоже Уровень 3;
  • вводить Event Sourcing — это отдельная сложность, не связанная с CQRS напрямую.

Когда переходить на уровень 3

Уровень 2 перестаёт хватать, когда:

  • бизнес-правил много, и они начинают копипаститься между handler-ами;
  • в команде разные термины для одного и того же — нужен общий язык домена;
  • появляются явные инварианты («нельзя оплатить неподтверждённый заказ», «остаток не может быть отрицательным»), и хочется, чтобы они жили в одном месте;
  • намечаются самодостаточные модули с чёткими границами.

Тогда — Уровень 3: DDD + Hexagonal.

Коротко

  • Одно правило: каждая бизнес-операция — отдельный UseCase + Handler.
  • Контроллер отдаёт UseCase в диспетчер, тот находит handler по типу — контроллер остаётся тонким.
  • Метрики для каждой операции встроены автоматически — добавлять руками не нужно.
  • Бизнес-логика живёт внутри handler, тестируется без инфраструктуры.
  • CQRS — опция: команды на UseCaseCommand, запросы на UseCaseQuery, Read Model для чтения.
  • Агрегаты, value objects и порты — это Уровень 3, здесь их не нужно.

Что почитать дальше

  • CQRS — разделение чтения и записи как отдельный паттерн.
  • Уровень 3: DDD + Hexagonal — когда нужны агрегаты и bounded context-ы.
  • Распределённые паттерны — Outbox для атомарной публикации событий.