← назад к разделу · ступень 2 из четырёх (0–3) · предыдущая: уровень 1
На первом уровне бизнес-операции растворены в сервис-классах с десятками методов. Через полгода непонятно, что сервис умеет, проверки расходятся между вызовами, метрики и аудит прикручиваются вручную каждый раз заново.
Уровень 2 вводит одно правило: каждая бизнес-операция — отдельный UseCase + Handler. Этого достаточно, чтобы не утонуть в сервис-классах и получить единый список того, что сервис умеет.
Одна операция — два классаспросят на собеседовании
Раньше сервис выглядел примерно так:
class OrderService {
Order create(...) { ... }
Order findById(...) { ... }
void cancel(...) { ... }
List<Order> list(...) { ... }
// ещё пять методов...
}
type OrderService struct{ ... }
func (s *OrderService) Create(ctx context.Context, ...) (*Order, error) { ... }
func (s *OrderService) FindByID(ctx context.Context, id string) (*Order, error) { ... }
func (s *OrderService) Cancel(ctx context.Context, id string) error { ... }
func (s *OrderService) List(ctx context.Context, ...) ([]Order, error) { ... }
// ещё пять методов...
class OrderService {
create(...): Promise<Order> { ... }
findById(...): Promise<Order> { ... }
cancel(...): Promise<void> { ... }
list(...): Promise<Order[]> { ... }
// ещё пять методов...
}
class OrderService:
def create(self, ...) -> Order: ...
def find_by_id(self, ...) -> Order: ...
def cancel(self, ...) -> None: ...
def list(self, ...) -> list[Order]: ...
# ещё пять методов...
Такой класс сложно тестировать изолированно, метрики не понять, что к чему относится, а ошибки в одном методе влияют на соседние.
На Уровне 2 каждая операция становится отдельной парой классов:
UseCase— структура с входными данными операции (что мы хотим сделать).Handler— логика выполнения: проверки, обращения к БД, ответ.
// Входные данные операции
record CreateOrderCommand(String customerId, List<String> items)
implements UseCaseCommand<CreatedOrderResult> {}
// Логика
@Component
@RequiredArgsConstructor
class CreateOrderCommandHandler
implements UseCaseHandler<CreateOrderCommand, CreatedOrderResult> {
private final OrderRepository orders;
@Override
@Transactional
public CreatedOrderResult handle(CreateOrderCommand cmd) {
Order order = orders.save(Order.create(cmd.customerId(), cmd.items()));
return new CreatedOrderResult(order.id());
}
}
// Входные данные операции
type CreateOrderCommand struct {
CustomerID string
Items []string
}
// Логика
type CreateOrderCommandHandler struct {
orders OrderRepository
uow UnitOfWork
}
var _ UseCaseHandler[CreateOrderCommand, CreatedOrderResult] = (*CreateOrderCommandHandler)(nil)
func (h *CreateOrderCommandHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (CreatedOrderResult, error) {
var result CreatedOrderResult
err := h.uow.Within(ctx, func(ctx context.Context) error {
order, err := h.orders.Save(ctx, NewOrder(cmd.CustomerID, cmd.Items))
if err != nil {
return err
}
result = CreatedOrderResult{OrderID: order.ID}
return nil
})
return result, err
}
// Входные данные операции
export class CreateOrderCommand implements UseCaseCommand<CreatedOrderResult> {
constructor(readonly customerId: string, readonly items: string[]) {}
}
// Логика
@Injectable()
export class CreateOrderCommandHandler
implements UseCaseHandler<CreateOrderCommand, CreatedOrderResult> {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
@Inject(TX_RUNNER) private readonly tx: TransactionRunner,
) {}
execute(cmd: CreateOrderCommand): Promise<CreatedOrderResult> {
return this.tx.run(async () => {
const order = await this.orders.save(Order.create(cmd.customerId, cmd.items));
return new CreatedOrderResult(order.id);
});
}
}
# Входные данные операции
@dataclass(frozen=True)
class CreateOrderCommand(UseCaseCommand[CreatedOrderResult]):
customer_id: str
items: list[str]
# Логика
class CreateOrderCommandHandler(UseCaseHandler[CreateOrderCommand, CreatedOrderResult]):
def __init__(self, orders: OrderRepository, uow: UnitOfWork) -> None:
self._orders = orders
self._uow = uow
async def handle(self, cmd: CreateOrderCommand) -> CreatedOrderResult:
async with self._uow:
order = await self._orders.save(Order.create(cmd.customer_id, cmd.items))
await self._uow.commit()
return CreatedOrderResult(order.id)
Имя операции кончается на Command или Query — по нему сразу видно, меняет она что-то или только читает. То же правило держит архитектурный тест в сборке, так что «просто назову покороче» не пройдёт.
Создание заказа — одна пара. Получение заказа по идентификатору — другая. Не «один 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 CreateOrderCommand(req.customerId(), req.items()));
return ResponseEntity.ok(result);
}
}
type OrderHandler struct {
dispatcher UseCaseDispatcher
}
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
result, err := h.dispatcher.Dispatch(r.Context(), CreateOrderCommand{CustomerID: req.CustomerID, Items: req.Items})
if err != nil {
writeProblem(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
@Controller('orders')
export class OrderController {
constructor(private readonly dispatcher: UseCaseDispatcher) {}
@Post()
create(@Body() req: CreateOrderRequest): Promise<CreatedOrderResult> {
return this.dispatcher.dispatch(new CreateOrderCommand(req.customerId, req.items));
}
}
@router.post("/orders")
async def create(req: CreateOrderRequest, dispatcher: UseCaseDispatcher = Depends()) -> CreatedOrderResult:
return await dispatcher.dispatch(CreateOrderCommand(req.customer_id, req.items))
Бизнес-проверки и обращения к базе данных живут внутри handler, нигде больше. Контроллер остаётся тонким: принял → передал → завернул ответ.
Контроллер не знает обработчика: он собирает объект операции и отдаёт диспетчеру, а тот по типу находит единственный обработчик.
Как диспетчер находит handler
«Находит по типу» — короткая формулировка, за которой стоит обычный поиск: у каждого обработчика объявлен тип команды, которую он обрабатывает, и диспетчер при старте строит соответствие «тип команды → обработчик». Дальше вызов — это поиск по типу пришедшего объекта.
Две ситуации ломают эту схему, и с обеими новичок сталкивается в первый же день.
Два обработчика на один тип команды. Возникает почти всегда одинаково: скопировали обработчик под новое поведение и забыли поменять тип команды; или подключили модуль, где обработчик с тем же типом уже есть. Правильное поведение — падать при старте приложения, а не выбирать один из двух. Причина в том, что молчаливый выбор даёт худший из отказов: приложение работает, но поведение зависит от порядка загрузки, и в другом окружении оно другое. Сообщение должно называть оба класса — иначе искать придётся руками.
Обработчика нет вовсе. Команда объявлена, обработчик не написан или не попал в сканирование (другой пакет, забытая пометка компонента, отключённый профиль). Здесь у отказа два варианта, и выбирать между ними надо осознанно:
| Когда падать | Плюс | Минус |
|---|---|---|
| При старте, проверив все команды | ошибка видна сразу, до трафика | нужен способ перечислить все команды |
| При вызове | проще | ошибка находится в проде, на первом же запросе |
Практика: проверять на старте. Это тот случай, когда архитектурный тест дешевле проверки в рантайме — тест в сборке проходит по всем классам команд и убеждается, что у каждой ровно один обработчик. Падает он на сборке, а не ночью.
Две частые ошибки, которые выглядят как «диспетчер не находит»:
- Обработчик не является компонентом. Забытая пометка — и класс просто не попал в контекст. Ошибка выглядит как «нет обработчика», а причина в сканировании.
- Тип объявлен слишком общо. Обработчик, объявленный на базовый тип команды вместо конкретного, перехватывает всё подряд. Это редкая, но самая запутанная из ошибок: работает, но не то.
И правило, которое снимает целый класс проблем: имя команды, имя обработчика и имя операции
в спеке совпадают. CancelOrderCommand обрабатывается CancelOrderCommandHandler и описан
карточкой CancelOrder. Тогда расхождение видно глазами, а гейт сверки спеки и кода ловит его
автоматически.
Метрики встроены автоматически
Одно из главных преимуществ уровня 2 — библиотека usecase-pattern сама считает метрики на каждую операцию: usecase_success_total — сколько раз прошло, usecase_failure_total — сколько раз упало, usecase_duration_seconds — сколько заняло. У каждой метрики две метки: usecase_name (имя класса операции) и application (имя сервиса).
Не нужно добавлять метрики руками в каждый метод — они появляются автоматически для любого нового handler-а.
Тесты на handler без инфраструктурыспросят на собеседовании
Handler — обычный класс с зависимостями. Его можно протестировать без запуска фреймворка, без HTTP:
class CreateOrderHandlerTest {
CreateOrderCommandHandler handler = new CreateOrderCommandHandler(
new InMemoryOrderRepository()
);
@Test
void createsOrder() {
var result = handler.handle(new CreateOrderCommand("cust-1", List.of("item-a")));
assertThat(result.orderId()).isNotNull();
}
}
func TestCreatesOrder(t *testing.T) {
handler := &CreateOrderCommandHandler{orders: NewInMemoryOrderRepository(), uow: NoopUnitOfWork{}}
result, err := handler.Handle(context.Background(), CreateOrderCommand{CustomerID: "cust-1", Items: []string{"item-a"}})
if err != nil {
t.Fatal(err)
}
if result.OrderID == "" {
t.Fatal("ожидали id созданного заказа")
}
}
describe('CreateOrderCommandHandler', () => {
const handler = new CreateOrderCommandHandler(new InMemoryOrderRepository(), new NoopTransactionRunner());
it('creates order', async () => {
const result = await handler.execute(new CreateOrderCommand('cust-1', ['item-a']));
expect(result.orderId).toBeDefined();
});
});
async def test_creates_order():
handler = CreateOrderCommandHandler(InMemoryOrderRepository(), NoopUnitOfWork())
result = await handler.handle(CreateOrderCommand("cust-1", ["item-a"]))
assert result.order_id is not None
Это быстрее, надёжнее и проще, чем тестировать через контроллер.
Куда девать код, общий для двух операций
Первый практический вопрос уровня. На уровне 1 общий кусок был приватным методом сервиса — рядом, под рукой. Обработчики лежат в разных классах, и приватный метод между ними не поделить.
Ответ зависит от того, что именно общее, и вариантов четыре — в порядке предпочтения.
1. Общее — бизнес-правило. Проверка «заказ можно менять только до отгрузки» нужна и отмене, и правке позиций. Это не вспомогательный код, это правило домена, и его место — в модели, а не в обработчике. На уровне 2 доменной модели ещё нет, поэтому рабочий компромисс — метод у сущности или отдельный небольшой класс правила, который обработчики зовут. Признак, что это именно правило: его нарушение — ошибка для клиента с отдельным кодом.
2. Общее — работа с данными. Загрузка заказа с позициями, поиск по нескольким условиям. Это место репозитория, а не общего сервиса. Повторяющийся в двух обработчиках блок запросов — сигнал, что в репозитории не хватает метода.
3. Общее — вспомогательная операция. Форматирование, преобразование, вычисление без обращения к внешнему миру. Обычная функция в утилитном классе или преобразователе; она не требует ни внедрения, ни тестов через контекст.
4. Общее — целый кусок сценария. Самый трудный случай: две операции делают одно и то же в три шага, различаясь началом. Здесь есть два законных выхода и один запрещённый:
- Выделить отдельный компонент под этот кусок с понятным именем по делу
(
OrderReservation,PriceCalculation), который обработчики зовут. Это нормально: обработчик не обязан содержать весь код, он обязан владеть порядком шагов. - Признать, что это одна операция с параметром. Иногда «две операции» отличаются одним флагом, и честнее иметь одну команду с полем, чем две почти одинаковые.
- Запрещено: обработчик зовёт другой обработчик. Это самый частый соблазн и самая дорогая ошибка уровня. Цепочка обработчиков рушит всё, ради чего уровень заводили: транзакция начинается дважды, метрики считаются дважды, список операций перестаёт быть списком того, что просит клиент, а отладка превращается в поиск, кто кого вызвал.
Два правила, по которым проверяют себя:
- Общий код не знает про операции. Он не принимает команду и не возвращает результат
операции. Как только общий компонент начал принимать
CancelOrderCommand, он стал обработчиком обработчика. - Дублирование в две-три строки лучше неправильной общности. Вынесенное «на всякий случай» обрастает флагами и ветвлениями, и через полгода его читать дороже, чем два похожих блока. Выносить стоит на третьем повторении, а не на втором.
CQRS — опция этого уровняспросят на собеседовании
CQRS (разделение операций чтения и записи) — не отдельный уровень зрелости, а опция Уровня 2. Включается, когда чтение и запись начинают мешать друг другу: длинные запросы замедляют систему, запросам нужен агрессивный кэш, а командам — строгие транзакции.
Явное разделение по типу. Команды реализуют маркер UseCaseCommand — они меняют состояние. Запросы реализуют UseCaseQuery — только читают. Это видно в типе и проверяется при разборе кода.
record CancelOrderCommand(String orderId)
implements UseCaseCommand<Void> {} // команда — меняет состояние
record GetOrderQuery(String orderId)
implements UseCaseQuery<OrderView> {} // запрос — только читает
type CancelOrderCommand struct{ OrderID string } // команда — меняет состояние
func (CancelOrderCommand) isCommand() {}
type GetOrderQuery struct{ OrderID string } // запрос — только читает
func (GetOrderQuery) isQuery() {}
export class CancelOrderCommand implements UseCaseCommand<void> { // команда — меняет состояние
constructor(readonly orderId: string) {}
}
export class GetOrderQuery implements UseCaseQuery<OrderView> { // запрос — только читает
constructor(readonly orderId: string) {}
}
@dataclass(frozen=True)
class CancelOrderCommand(UseCaseCommand[None]): # команда — меняет состояние
order_id: str
@dataclass(frozen=True)
class GetOrderQuery(UseCaseQuery[OrderView]): # запрос — только читает
order_id: str
Read Model — отдельная модель для чтения. Вместо того чтобы читать из той же таблицы, что и команды, запросы идут к отдельному представлению: денормализованной таблице, материализованному представлению или кэшу. Это позволяет настроить чтение независимо от записи.
Команды возвращают минимум данных — идентификатор, краткий результат или ничего. За полными данными клиент делает отдельный запрос. Иначе смысл разделения теряется. CreatedOrderResult из примера выше — как раз такой минимум: внутри один идентификатор заказа, а не весь заказ.
Согласованность с задержкой. Read Model не обновляется сам собой — его надо чем-то наполнять: писать в него по событию записи, пересчитывать по расписанию или, если это материализованное представление PostgreSQL, звать REFRESH MATERIALIZED VIEW. Пока обновление не дошло, запрос вернёт слегка устаревшие данные — это нужно явно зафиксировать в поведении системы.
Между общей таблицей и отдельной моделью чтения есть промежуточные ступени. Разделение команд и запросов по типу уже даёт пользу, а строить и наполнять отдельную модель чтения ещё рано. Между этими состояниями — четыре ступени, и переходить надо по одной:
| Ступень | Что делаем | Когда достаточно |
|---|---|---|
| 0 | команды и запросы разделены по типу, данные общие | почти всегда на старте |
| 1 | у запросов свои структуры ответа, не те же, что у команд | как только ответ чтения перестал совпадать с моделью записи |
| 2 | запросы читают мимо слоя записи: свой запрос к базе, только нужные поля | когда чтение тянет лишние данные или связи |
| 3 | представление в той же базе (обычное или материализованное) | когда запрос сложный и повторяется |
| 4 | отдельная модель чтения, наполняемая событиями или по расписанию | когда нагрузка на чтение мешает записи |
Что важно понимать про эту лестницу: первые три ступени не вводят задержку. Данные те же, транзакция та же, читается всё в момент запроса — значит нет ни рассинхронизации, ни необходимости что-то объяснять клиенту. Цена появляется только на четвёртой ступени, и именно поэтому до неё не доходят, пока не прижало.
Признаки, по которым видно, что пора на следующую ступень: ступень 1 — в ответе чтения появились поля, которых нет в модели записи; ступень 2 — запрос грузит связанные данные, которые не нужны; ступень 3 — один и тот же сложный запрос повторяется в нескольких местах; ступень 4 — тяжёлые запросы замеряемо мешают записи или чтению нужен другой способ хранения.
Где фиксируют задержку и как её видит клиент. На четвёртой ступени согласованность перестаёт быть мгновенной, и «зафиксировать в поведении системы» означает три конкретных места:
- В спецификации, в карточке запроса. У запроса есть строка про источник чтения и согласованность: «источник — модель чтения, задержка до 5 секунд». Это часть контракта операции, а не примечание.
- В контракте API. Ответ запроса несёт отметку актуальности — время, на которое данные верны, или версию. Клиенту это позволяет отличить «ещё не обновилось» от «не изменилось».
- В поведении после команды. Самое важное и самое пропускаемое. Клиент, отправивший команду, почти всегда сразу читает результат — и видит старые данные. Рабочие приёмы: команда возвращает минимум, которого хватает для отображения без перечитывания; клиент показывает своё изменение оптимистично, не дожидаясь чтения; чтение сразу после записи идёт к источнику записи, а не к модели чтения; либо команда возвращает версию, и клиент ждёт, пока чтение отдаст не меньшую.
Чего делать не стоит: скрывать задержку паузой перед чтением. «Подождём секунду и перечитаем» — не решение, а лотерея, которая проигрывает под нагрузкой именно тогда, когда это заметнее всего.
Что не нужно делать на этом уровне
Уровень 2 намеренно прост. Не нужно:
- заводить агрегаты, value objects и доменные события — это Уровень 3;
- строить порты и адаптеры — тоже Уровень 3;
- вводить Event Sourcing — это отдельная сложность, не связанная с CQRS напрямую.
Когда переходить на уровень 3
Уровень 2 перестаёт хватать, когда:
- бизнес-правил много, и они начинают копипаститься между handler-ами;
- в команде разные термины для одного и того же — нужен общий язык домена;
- появляются явные инварианты («нельзя оплатить неподтверждённый заказ», «остаток не может быть отрицательным»), и хочется, чтобы они жили в одном месте;
- намечаются самодостаточные модули с чёткими границами.
Тогда — Уровень 3: DDD + Hexagonal.
Глубже: из сервиса на тридцать методов в пары UseCase и Handler, не останавливая разработкурасширенное
Уровни описаны как состояния, а вопрос, который встаёт в живом проекте, это переход: есть OrderService на тридцать методов, разработка идёт, и остановиться на месяц нельзя.
Порядок. Первой переезжает не самая простая операция, а та, которую сейчас меняют: раз её всё равно трогать, её оформляют как UseCase и Handler, а старый метод сервиса становится вызовом обработчика через диспетчер или удаляется, если его звал только контроллер. Каждый такой перенос это отдельный pull request, и после него сборка и тесты зелёные. Через несколько недель половина методов переехала по ходу обычной работы, и остаток переносят целенаправленно, по одному в день.
Как жить с наполовину мигрированным. Нормально: контроллеры зовут диспетчер для новых операций и старый сервис для старых, метрики и тесты появляются у переехавших, у остальных остаётся как было. Что запрещено: новая операция в старом сервисе. Правило ArchUnit «классы с суффиксом Service в этом пакете не растут» или список методов старого сервиса, зафиксированный тестом, не дают откатиться незаметно.
Что переносится вместе с методом. Проверки входа уезжают в аннотации UseCase, транзакция на обработчик, вызовы других методов того же сервиса становятся вызовами других UseCase через диспетчер или, если это была общая внутренняя логика, выделяются в компонент, которым пользуются оба обработчика. Метод, который вызывает пять других методов сервиса, это признак, что это не одна операция, а сценарий, и его режут на UseCase по границам транзакций.
Спека при этом. Уровень 0 снимает «как есть» до начала, и каждая перенесённая операция получает карточку; по разнице между снимком и карточками видно, сколько осталось. Так переход измерим: не «переписываем», а «переехало 18 из 30».
Глубже: ошибки и их путь наружурасширенное
«Единый способ обработки ошибок» назван причиной перейти на этот уровень, и стоит показать, где исключение ловится и во что превращается, потому что в примерах уровня этого нет.
В обработчике ошибки не ловят. Обработчик бросает доменные исключения с кодом из карточки команды (OrderNotFoundException, OrderAlreadyConfirmedException), и код ошибки это часть контракта операции, а не строка сообщения. Исключения инфраструктуры (база недоступна) обработчик тоже не ловит: это не его решение.
На границе одна точка перевода: обработчик исключений уровня приложения сопоставляет класс исключения со статусом и телом ответа в формате ошибок API: не найдено это 404, конфликт состояния 409, нарушенное бизнес-правило 422 с кодом BR-O01 в теле, ошибка инфраструктуры 503 с повторяемостью, всё остальное 500 без внутренностей. Сопоставление живёт в одном классе, и новый код ошибки в карточке команды означает одну строку в нём. Для события-потребителя граница другая: исключение из обработчика решает, повторять сообщение или отправлять в очередь недоставленных, и это тоже одна точка.
Что не делают. Не ловят исключение в обработчике, чтобы вернуть null или флаг; не превращают ошибку в код возврата, который контроллер должен проверить; не пишут try/catch в контроллере. Диспетчер сверху добавляет метрики и журнал с именем UseCase и кодом ошибки, поэтому по метрикам видно, какая операция какими ошибками отвечает. Модель ошибок целиком, форматы тела и связь с идемпотентностью разобраны в разделе про обработку ошибок и в статьях REST про ошибки; здесь важно, что уровень 2 даёт ей одно место, а не единый стиль в тридцати методах.
Коротко
- Одно правило: каждая бизнес-операция — отдельный
UseCase+Handler. Контроллер отдаётUseCaseв диспетчер, тот находит handler по типу — контроллер остаётся тонким. - Метрики для каждой операции встроены автоматически — добавлять руками не нужно. Бизнес-логика живёт внутри handler, тестируется без инфраструктуры.
- CQRS — опция: команды на
UseCaseCommand, запросы наUseCaseQuery, Read Model для чтения. - Агрегаты, value objects и порты — это Уровень 3, здесь их не нужно.
- Переход с уровня 1 идёт по одной операции за PR, начиная с той, что меняют сейчас; новые операции в старом сервисе запрещены правилом, а прогресс измеряется карточками спеки против снимка уровня 0.
- Ошибки в обработчике не ловят: доменные исключения с кодами из карточки летят на границу, где одна точка переводит их в статусы и тело формата ошибок, а для событий решает повтор или недоставленное.
- Диспетчер ищет обработчик по типу команды: два обработчика на один тип или отсутствие обработчика должны падать на старте или на архитектурном тесте, а не выбираться молча в рантайме. Имя команды, имя обработчика и имя карточки в спеке совпадают — тогда расхождение видно глазами и ловится гейтом.
- Общий код раскладывают по природе: правило — в модель, работа с данными — в репозиторий, вспомогательное — в функцию, кусок сценария — в отдельный компонент; обработчик не зовёт обработчик.
- Между общими данными и отдельной моделью чтения четыре ступени: свои структуры ответа, чтение мимо слоя записи, представление в той же базе и только потом отдельная модель; первые три не вводят задержку.
- Задержку фиксируют в карточке запроса, в отметке актуальности ответа и в поведении после команды: вернуть минимум для отображения, показать оптимистично, читать после записи из источника или ждать версию.
Что пощупать
Уровень 2 целиком стоит во взрослом каталоге практикума remodov/marketplace-system: каждая операция — запись-команда с маркером UseCaseCommand и свой обработчик, контроллер реализует сгенерированный из OpenAPI интерфейс и только отдаёт команду диспетчеру, правило живёт в обработчике, а не в контроллере, чужой товар отдаётся как несуществующий. Рядом, в стартовом каталоге, та же смена цены сделана по-простому в три файла: разницу удобно смотреть в двух окнах.
Код: usecase/product, контроллер, контракт.
Сделаем сами
Ветка step-07-usecase-and-handler — ручки смены цены нет: сначала контракт, потом UseCase, Handler, порт и адаптер, ChangeProductPriceIntegrationTest из шести проверок красный, условие по ссылке в TASK.md.
Что почитать дальше
- Уровень 1: Слоёный напоминает, что осталось позади: операции, растворённые в сервис-классах.
- CQRS — разделение чтения и записи как отдельный паттерн.
- Уровень 3: DDD + Hexagonal — когда нужны агрегаты и bounded context-ы.
- Распределённые паттерны — Outbox для атомарной публикации событий.