← назад к разделу · ступень 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, нигде больше. Контроллер остаётся тонким: принял → передал → завернул ответ.

HTTP-запрос CreateOrderRequest Контроллер собирает UseCase CreateOrderCommand Диспетчер ищет по типу нашёл обработчик Handler проверки и база CreatedOrderResult Результат

Контроллер не знает обработчика: он собирает объект операции и отдаёт диспетчеру, а тот по типу находит единственный обработчик.

Как диспетчер находит 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.

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