open atlas
↑ К треку
Архитектурные паттерны ARCH · 05 · 02

Use cases и interactors

Кольцо Use Cases оркестрирует entities для достижения одной цели приложения. Interactor — класс, реализующий use case: получает request model, вызывает entities и возвращает response model — без типов фреймворка.

ARCH Middle ◷ 22 min
Уровень
ОсновыJuniorMiddleSenior

На B2B-платформе бэкенд вырос до более чем сорока методов сервисов. Когда пришёл новый инженер, его задачей при онбординге было найти, где происходит генерация счёта. Он искал generateInvoice и обнаружил вызовы в шести местах: HTTP-контроллер, фоновое задание, обработчик вебхука, скрипт батч-сверки, административный CLI и сервис обработки email. Каждое место передавало слегка разные аргументы. Два из них пропускали проверку «billing hold». Одно использовало объект подключения к базе данных напрямую. В кодовой базе не было единственного места, которое говорило бы: «вот что значит сгенерировать счёт — входные данные, шаги, результат». Логика была размазана по коду вызывающих. Когда Clean Architecture описывает кольцо Use Cases, это ответ именно на эту проблему: должен быть один класс на один application use case, и этот класс должен кодировать всё необходимое — форму входных данных, оркестрацию entities, форму выходных данных. Interactor — это тот самый класс.

Кольцо Use Cases и что ему принадлежит

В кольцевой модели Clean Architecture кольцо Use Cases находится непосредственно снаружи кольца Entities. Оно содержит бизнес-правила, специфичные для приложения — правила, выражающие, что именно это приложение делает с entities.

Различие важно. Entity содержит общеприкладные бизнес-правила: у Order должна быть хотя бы одна позиция перед подтверждением; итог Invoice должен равняться сумме позиций. Эти правила верны в любом приложении, построенном на этом бизнесе. Use case содержит прикладные правила: чтобы сгенерировать счёт, сначала проверить, что заказ подтверждён, затем проверить billing holds, затем создать entity Invoice, затем опубликовать событие. Эти шаги специфичны для процесса данного приложения.

Кольцу Use Cases принадлежит:

  • Один класс на use case (interactor)
  • Интерфейс input port (что принимает use case)
  • Интерфейс output port (что производит use case)
  • Request model (структура данных, поступающая внутрь)
  • Response model (структура данных, выходящая наружу)

Паттерн interactor

Interactor — класс, реализующий один use case. Он содержит шаги прикладной логики этого use case и ничего больше. Он не знает об HTTP, SQL, JSON или любой концепции фреймворка.

// Все типы здесь — в кольце Use Cases, без импортов фреймворка
interface GenerateInvoiceInputPort {
  execute(request: GenerateInvoiceRequest): Promise<void>;
}

interface GenerateInvoiceOutputPort {
  presentSuccess(response: GenerateInvoiceResponse): void;
  presentError(error: InvoiceGenerationError): void;
}

class GenerateInvoiceInteractor implements GenerateInvoiceInputPort {
  constructor(
    private readonly orders: IOrderRepository,
    private readonly invoices: IInvoiceRepository,
    private readonly billingHolds: IBillingHoldChecker,
    private readonly presenter: GenerateInvoiceOutputPort,
  ) {}

  async execute(request: GenerateInvoiceRequest): Promise<void> {
    const order = await this.orders.findById(request.orderId);
    if (!order) {
      this.presenter.presentError(new InvoiceGenerationError('ORDER_NOT_FOUND'));
      return;
    }
    if (order.status !== OrderStatus.Confirmed) {
      this.presenter.presentError(new InvoiceGenerationError('ORDER_NOT_CONFIRMED'));
      return;
    }
    const hold = await this.billingHolds.checkForHold(order.customerId);
    if (hold.active) {
      this.presenter.presentError(new InvoiceGenerationError('BILLING_HOLD_ACTIVE'));
      return;
    }
    const invoice = Invoice.createFor(order);
    await this.invoices.save(invoice);
    this.presenter.presentSuccess(new GenerateInvoiceResponse(invoice.id, invoice.total));
  }
}

GenerateInvoiceInteractor — единственное каноническое место, говорящее: «вот что значит сгенерировать счёт». Каждый вызывающий — HTTP-контроллер, batch-задание, CLI, обработчик вебхука — вызывает этот interactor. Ни один вызывающий не может пропустить проверку billing hold, потому что она внутри interactor, а не в вызывающем.

Request models и response models — зачем они нужны

Распространённый ранний shortcut — передавать объект HTTP-запроса прямо в use case:

// Неверно: кольцо Use Cases импортирует тип фреймворка
async execute(req: ExpressRequest): Promise<ExpressResponse>

Это создаёт source-code зависимость от HTTP-фреймворка в кольце Use Cases — зависимость, указывающую наружу, что нарушает концентрическое правило. Хуже того, use case становится нетестируемым без реального объекта HTTP-запроса.

Исправление — request models и response models: простые структуры данных, определённые в кольце Use Cases, несущие именно те данные, которые нужны use case, в domain-терминах.

// Верно: кольцо Use Cases определяет собственные формы данных
class GenerateInvoiceRequest {
  constructor(
    public readonly orderId: OrderId,
    public readonly requestedBy: UserId,
  ) {}
}

class GenerateInvoiceResponse {
  constructor(
    public readonly invoiceId: InvoiceId,
    public readonly total: Money,
    public readonly generatedAt: Date,
  ) {}
}

HTTP-контроллер (в Interface Adapters) парсит HTTP-запрос и конструирует GenerateInvoiceRequest. Interactor никогда не видит ExpressRequest. Interactor производит GenerateInvoiceResponse. HTTP-presenter (тоже в Interface Adapters) конвертирует его в JSON HTTP-ответ. Перевод между типами фреймворка и типами use case — это полная работа кольца Interface Adapters.

Почему это работает

Зачем определять отдельные request/response models вместо передачи plain object или generic dictionary? Потому что request model документирует именно то, что нужно use case. Это API-контракт use case — верифицируемый type checking, обнаруживаемый навигацией IDE, явный при code review. Plain object или generic dictionary скрывает этот контракт; любой вызывающий может передать любую форму и падает только в runtime. Request model — это документация, которую компилятор принудительно соблюдает.

Input ports и output ports

Interactor взаимодействует через два интерфейса портов, оба определённых в кольце Use Cases:

Input port (GenerateInvoiceInputPort): интерфейс, который реализует interactor. Вызывающие (HTTP-контроллеры, CLI-адаптеры, batch-задания) импортируют этот интерфейс и вызывают execute. Они никогда не импортируют конкретный класс interactor. Это значит, что всю реализацию interactor можно заменить или замокировать в тестах без изменений в вызывающих.

Output port (GenerateInvoiceOutputPort): интерфейс, который interactor вызывает для доставки результатов. Interactor не возвращает значение — он вызывает output port, который реализует кольцо Interface Adapters (как presenter). Это инверсия зависимостей, применённая к стороне ответа: interactor не знает о HTTP-статус-кодах, JSON-сериализации или стриминге — он вызывает метод на domain-языке вроде presentSuccess(response) и позволяет presenter решить, как его отрендерить.

Эта двухпортовая структура отражает primary и secondary ports hexagonal architecture, теперь применённую конкретно на границе кольца Use Cases.

lesson.inset.note

Многие реализации пропускают output port и просто возвращают response model из метода execute. Это проще и всё ещё валидно — упускается лишь возможность, чтобы interactor управлял несколькими каналами представления из одного вызова. Для большинства use cases прямой возврат response model — прагматичный выбор. Паттерн output port наиболее полезен, когда один use case должен одновременно обновить несколько выходных каналов (обновить UI presenter, опубликовать событие, записать лог) без знания о том, что это за каналы. Оба подхода сохраняют dependency rule.

Викторина

GenerateInvoiceInteractor платформы принимает GenerateInvoiceRequest и вызывает GenerateInvoiceOutputPort presenter. Команда хочет добавить REST-эндпоинт И GraphQL-эндпоинт, оба запускающие генерацию счёта. Что изменится, а что останется прежним?

Викторина

Разработчик утверждает: «Паттерн output port добавляет лишнее косвенное обращение. Наш interactor должен просто возвращать GenerateInvoiceResponse и дать контроллеру его сериализовать». Это валидно, и что при этом теряется?

Викторина

Команда обнаруживает, что PlaceOrderInteractor и GenerateInvoiceInteractor оба должны проверять billing holds на клиента. Они выносят CheckBillingHoldService с общей логикой. В каком кольце должен жить CheckBillingHoldService?

Вспомните перед уходом
  1. 01
    Что interactor владеет и что ему запрещено импортировать?
  2. 02
    Какова роль request model, и почему это не объект HTTP-запроса?
  3. 03
    Что такое input ports и output ports на границе кольца Use Cases?
Итог

Кольцо Use Cases решает проблему «размазанной логики»: когда одна и та же прикладная операция реализована по-разному в шести callsites, нет единственного места, кодирующего смысл операции. Паттерн interactor решает это напрямую — один класс, один use case, одно место для полной логики.

Interactor получает request model (простая структура данных, определённая в кольце Use Cases, с domain-типизированными полями без типов фреймворка), оркестрирует entities через интерфейсно-определённые репозитории и сервисы (также определённые в Use Cases), и доставляет результаты через output port или возвращаемый response model. Каждый вызывающий — HTTP-контроллер, CLI-адаптер, batch-задание, обработчик вебхука — конструирует request model и вызывает input port. Все они проходят одну логику, включая все одинаковые проверки.

Двухпортовая структура (input port и output port) применяет инверсию зависимостей симметрично: вызывающие зависят от интерфейса input port (зависимость внутрь от Interface Adapters к Use Cases); interactor вызывает output port (кольцо Interface Adapters реализует интерфейс output port, определённый в Use Cases). Ни один код внутреннего кольца не называет тип внешнего.

Следующий урок рассматривает Entity core — самое внутреннее кольцо, содержащее наиболее стабильный код, — и честную стоимость всей этой структуры: налог на маппинг, косвенное обращение и обстоятельства, при которых накладные расходы не оправдываются.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.