← назад к разделу · уровень 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 для атомарной публикации событий.