← назад к разделу

В гексагональной архитектуре есть одно ключевое правило: ядро (core) не должно знать про инфраструктуру. Оно не знает, PostgreSQL у нас или MySQL, Sber или какая-то другая платёжка, Kafka или RabbitMQ. Это позволяет менять инфраструктуру, не трогая бизнес-логику.

Но core всё равно должен куда-то ходить — читать и писать данные, вызывать платёжный шлюз, публиковать события. Как это сделать, не зная про инфраструктуру?

Ответ — ports. Core описывает интерфейс «мне нужно вот это», а конкретная реализация появляется только в адаптере. Зависимости текут от адаптеров к ядру, а не наоборот.

ядро (core) адаптер вызов через интерфейс порта зависимость при сборке

Стрелки смотрят в разные стороны: зовёт ядро, а зависит адаптер, и в этом состоит инверсия зависимостей.

Где живёт port и как называется

Outbound port — это интерфейс в core/<bc>/port/out/. Именно в core/, не в адаптере. Core объявляет, что ему нужно, адаптер это реализует.

core/src/main/java/<pkg>/
└── domain/
    └── orders/                          # bounded context
        ├── aggregate/Order.java
        ├── exception/                   # доменные исключения, в том числе для портов
        │   ├── PaymentPortException.java
        │   └── PaymentNotFoundException.java
        └── port/out/                    # ← port-интерфейсы здесь
            ├── OrderRepository.java     # работа с агрегатом в БД
            ├── OrderViewRepository.java # read-проекция для CQRS
            ├── PaymentPort.java         # внешний платёжный шлюз
            ├── NotificationPort.java    # SMS / email
            └── OrderEventPublisher.java # исходящие события

Соглашение по именам простое:

Что делает portКак называтьПример
Сохраняет и читает агрегат<X>RepositoryOrderRepository
Возвращает read-проекции (CQRS)<X>ViewRepositoryOrderViewRepository
Ходит во внешнюю HTTP-систему<Y>PortPaymentPort, SmsPort
Публикует события напрямую<Z>EventPublisherOrderEventPublisher

Repository без суффикса Port — историческое соглашение из DDD, имя говорит само за себя. Для всего остального — суффикс Port: сразу видно, что это контракт к внешней системе.

Почему у витрины чтения отдельный порт, а не метод в OrderRepository — вопрос, который возникает сразу и на который есть три причины, а не вкусовщина.

Разные типы на выходе. OrderRepository возвращает агрегат — объект с правилами, который можно менять. Витрина возвращает плоскую запись под конкретный ответ. Смешав их в одном интерфейсе, вы получаете интерфейс, у которого половина методов отдаёт домен, половина — структуры чтения; читающий его не понимает, что это за штука.

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

Разные реализации. Витрина может читать другую таблицу, другую базу или поисковый движок; репозиторий агрегата — нет. Один интерфейс с двумя реализациями в разных модулях невозможен.

И практическое следствие для команд: метод чтения, добавленный в репозиторий агрегата «на минутку» (findAllByStatusAndDateBetween), — самое частое начало размывания. Он тянет за собой параметры фильтрации, постраничную навигацию и структуры ответа, и через полгода репозиторий записи обслуживает интерфейс пользователя. Разбор того же с другой стороны — в статье про сторону запросов.

Две реализации одного порта

«Завтра заменить один адаптер другим — обработчик не меняется» верно, пока реализация одна. Как только их две, контейнер внедрения зависимостей перестаёт понимать, какую подставлять, и приложение падает при старте с ошибкой о неоднозначности бина. Разбираем случаи, потому что они разные.

Случай 1: одна реализация в проде, другая в другом окружении. Заглушка платёжного шлюза на тестовом контуре, реализация для отладки без сети. Правильный способ — условная регистрация по настройке: в контейнере оказывается ровно один бин, выбранный профилем или свойством. Никакой неоднозначности нет, потому что второй бин не создаётся.

Случай 2: две реализации нужны одновременно, и выбор делается по данным. Два платёжных провайдера, и провайдер зависит от способа оплаты; два перевозчика; отправка через почту или мессенджер. Здесь неправильно просить контейнер выбрать: выбор — часть предметной области, и он должен быть виден в коде.

Рабочая форма — порт выбора плюс реализации, отвечающие на вопрос «это моё?»:

// core: порт с признаком применимости
public interface PaymentGateway {
    boolean supports(PaymentMethod method);
    PaymentResult charge(ChargeRequest request);
}

// core: сценарий получает ВСЕ реализации и выбирает сам
public class ChargeService {
    private final List<PaymentGateway> gateways;

    public PaymentResult charge(Order order) {
        return gateways.stream()
                .filter(g -> g.supports(order.paymentMethod()))
                .findFirst()
                .orElseThrow(() -> new NoGatewayForMethod(order.paymentMethod()))
                .charge(ChargeRequest.of(order));
    }
}

Что здесь важно: выбор живёт в ядре и читается как правило домена, а не спрятан в настройках контейнера; добавление третьего провайдера не меняет сценарий; и отсутствие подходящей реализации — понятное доменное исключение, а не загадочная ошибка внедрения.

Случай 3: две реализации одного и того же — признак двух разных портов. Если реализации отвечают на разные потребности («сохранить заказ в базу» и «сохранить заказ в файл для выгрузки»), это не один порт с двумя реализациями, а два порта с разными именами. Проверка: если в обработчике приходится думать, какую реализацию взять, и ответ не зависит от данных — вы соединили два разных намерения.

Чего лучше избегать. Приоритет реализации по умолчанию (@Primary) — работает, но делает выбор невидимым: через полгода никто не помнит, почему подставляется эта. Выбор по имени бина в точке внедрения — то же самое, только с опечаткой в строке. Оба приёма годятся для временного состояния при переходе с одной реализации на другую, и в этой роли они полезны: старая остаётся в коде, новая помечена как основная, откат — одна строка.

Методы port'а работают с доменными типами

Это ключевой момент. Интерфейс port'а должен выглядеть как часть домена — никаких структур из внешних SDK, никаких сущностей из слоя базы данных.

// Правильно — domain-типы
public interface PaymentPort {
    RegisterResult register(RegisterCommand cmd);   // RegisterCommand — доменный объект
    void cancel(PaymentId paymentId);               // PaymentId — доменный Value Object
}
// Неправильно — Sber-specific структуры в core
public interface PaymentPort {
    SberRegisterResponse register(SberRegisterRequest req);
}

Что плохо в варианте с SberRegisterRequest:

  • Core теперь знает про Sber. Если завтра меняем платёжку, приходится переписывать не только адаптер, но и всё, что использует этот интерфейс.
  • Тесты на handler'ах вынуждены создавать SberRegisterRequest — а это детали инфраструктуры в чистых unit-тестах.
  • В core просочились JSON-аннотации, snake_case-поля и прочие детали Sber API.

PaymentPort принимает доменный RegisterCommand (поля: amount, orderId, description) и возвращает доменный RegisterResult (paymentId, redirectUrl). Маппинг в структуры Sber-API живёт в SberClientAdapter внутри отдельного модуля адаптера.

Размер порта: потребность, а не возможности

Самая частая ошибка новичка — толстый порт, повторяющий возможности внешней системы: двадцать методов, потому что «у базы есть двадцать запросов» или «у платёжного шлюза двадцать ручек». Так получается интерфейс, который ничего не изолирует: ядро зависит от всей чужой поверхности, и замена адаптера означает реализацию двадцати методов, из которых используются четыре.

Правило: порт описывает потребность ядра, а не возможности системы. Пишется он от сценариев: какие вопросы ядро задаёт и какие действия просит выполнить. Если метод не вызывается ни одним сценарием, его в порту нет.

Как выглядит толстый порт и что с ним делать:

// Плохо: порт повторяет возможности платёжного шлюза
public interface PaymentPort {
    PaymentResult charge(ChargeRequest r);
    PaymentResult refund(RefundRequest r);
    PaymentStatus status(PaymentId id);
    List<Payment> search(PaymentFilter f);          // используется только отчётом
    void updateCustomerCard(CustomerId c, Card card);  // вообще другой сценарий
    Receipt receipt(PaymentId id);                 // нужно только уведомлениям
    // ещё четырнадцать методов «на всякий случай»
}
// Хорошо: три порта по трём потребностям, у каждого свой вызывающий
public interface ChargePort {                 // нужен сценарию оплаты
    PaymentResult charge(ChargeRequest request);
}

public interface RefundPort {                 // нужен сценарию возврата
    RefundResult refund(RefundRequest request);
}

public interface PaymentStatusPort {          // нужен сверке и поддержке
    PaymentStatus status(PaymentId id);
}

Обратите внимание: реализация у всех трёх может быть одна и та же — один класс адаптера реализует три интерфейса, если все три ходят в один шлюз. Разделение не про число классов адаптера, а про то, что каждый сценарий видит только то, что ему нужно.

Признаки, что порт пора разрезать:

  • Методов больше пяти-шести, и они относятся к разным сценариям.
  • Ни один вызывающий не использует больше двух методов — верный признак, что это несколько портов в одном.
  • Тест сценария вынужден заглушать методы, которые сценарий не вызывает (потому что интерфейс требует их реализовать) — это та самая цена толстого порта, которую замечают первой.
  • Порт растёт при каждой новой задаче. Порт, который меняется чаще ядра, не изолирует ничего.

И обратная крайность, тоже встречается: порт на один метод для каждой мелочи. Десять интерфейсов по одному методу, все реализованные одним классом, — это шум. Разумная гранулярность: порт на связную потребность, обычно два-четыре метода («найти и сохранить заказ», «списать и вернуть деньги»). Ориентир простой: если два метода почти всегда вызываются одним и тем же сценарием, им место в одном порту.

Когда порт не нужен

Заводить порт к каждой зависимости — типичный перегиб, после которого в ядре появляются ClockPort, UuidPort, LoggerPort и ConfigPort. Граница есть, и она проходит по трём вопросам.

Вопрос 1: это часть языка платформы или внешняя система? Интерфейсы и типы из стандартной библиотеки (время, коллекции, математика) — часть языка, и оборачивать их не надо. Подменяемость времени решается тем, что стандартный Clock уже интерфейс: ядро принимает его в конструктор, тест подставляет фиксированный, и порт не нужен. Разбор — в разделе «Глубже» ниже.

Вопрос 2: нужна ли подменяемость в тестах или в проде? Порт заводят, чтобы подменить реализацию: в тесте на заглушку, в проде на другого поставщика. Если подменять нечего и незачем (математика, форматирование, преобразование внутри ядра), порт — лишний уровень.

Вопрос 3: пересекает ли вызов границу процесса или хранит состояние? База, брокер, чужой сервис, файловая система, отправка почты — да, нужен порт. Вычисление внутри памяти — нет.

Практический список, что порт не заслуживает:

  • Журналирование. Ядро может писать в журнал через стандартный фасад: он не тянет инфраструктуру, а заворачивать его в порт означает создать свой мини-фасад над фасадом. Спорный случай — если журнал считается частью наблюдаемого поведения (аудит), но тогда это уже не журнал, а порт аудита с доменным смыслом.
  • Настройки. Значение настройки передают в конструктор при сборке, а не читают из ядра через порт.
  • Генерация идентификаторов и время — см. вопрос 1: это не порты, а зависимости-интерфейсы из платформы. Порт заводят, только если генерация особая (идентификатор от внешней системы, номер из последовательности базы).
  • Преобразование данных, сериализация внутри ядра, работа с текстом.
  • То, у чего одна реализация и она чистая (без сети, файлов и состояния) — это просто класс ядра, а не адаптер за портом.

И признак перегиба, который легко проверить: если реализация порта лежит в том же модуле, что интерфейс, и никуда не ходит, — порт не нужен, это класс. Порт имеет смысл ровно там, где реализация живёт снаружи.

Порт в тестах: заглушка, поддельная реализация или мок

То, ради чего порт и вводят, — и то, о чём чаще всего не договариваются. Три способа, и у каждого своё место.

Поддельная реализация (в памяти) — предпочтительный способ для репозиториев. Полноценная маленькая реализация порта на коллекции в памяти: она ведёт себя как настоящая на уровне контракта, её пишут один раз и переиспользуют во всех тестах.

// core/src/test/java/.../InMemoryOrderRepository.java
public class InMemoryOrderRepository implements OrderRepository {
    private final Map<OrderId, Order> store = new HashMap<>();

    @Override public Optional<Order> byId(OrderId id) { return Optional.ofNullable(store.get(id)); }
    @Override public void save(Order order) { store.put(order.id(), order); }
}

Почему это лучше мока: тест читается как сценарий («сохранили заказ, выполнили команду, проверили состояние»), а не как список настроенных вызовов; тесты не ломаются при изменении способа сохранения; и одна поддельная реализация обслуживает десятки тестов.

Заглушка с заданным ответом — для внешних систем. Порт платёжного шлюза в тесте отвечает «одобрено» или «отказано»: это ровно то, что нужно проверить в сценарии, и сложная реализация не требуется. Пишут её как маленький класс или как мок — здесь разница несущественна.

Мок с проверкой вызова — только когда сам вызов и есть результат. «Письмо отправлено», «событие опубликовано» — наблюдаемое поведение, и его проверяют фактом вызова. Во всех остальных случаях проверка вызовов привязывает тест к реализации сценария.

Где живут эти реализации. Поддельные реализации портов — в тестовых исходниках ядра (core/src/test), рядом с тестами, которые их используют. Это важно: они не должны попадать в основной код (иначе однажды окажутся в проде) и не должны лежать в модуле адаптера (тогда тесты ядра начнут зависеть от адаптера, и вся идея сломается).

Если поддельные реализации нужны и в тестах других модулей, их выносят в отдельный тестовый артефакт ядра (test-fixtures в терминах сборки) — тогда адаптеры и модуль сборки могут их подключить, а основной код — нет.

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

Иерархия исключений

Port — это контракт, и исключения тоже часть контракта. Делятся они по тому, на каком языке описывают отказ. В core/ живут базовый класс и те отказы, которые понятны в терминах домена: «платёж не найден», «банк отказал». В адаптерах — подклассы, привязанные к конкретной технологии: «Sber вернул 500», «таймаут HTTP-клиента».

Лежат эти классы не в port/out/, а рядом — в core/<bc>/exception/. В пакете порта остаются только интерфейсы, и это потом легко проверить архитектурным тестом.

// core/domain/orders/exception/PaymentPortException.java
public abstract class PaymentPortException extends RuntimeException {
    protected PaymentPortException(String msg, Throwable cause) {
        super(msg, cause);
    }
}

// core/domain/orders/exception/PaymentNotFoundException.java
public class PaymentNotFoundException extends PaymentPortException {
    public PaymentNotFoundException(PaymentId id) {
        super("Payment not found: " + id, null);
    }
}

// core/domain/orders/exception/PaymentDeclinedException.java
public class PaymentDeclinedException extends PaymentPortException {
    private final String reason;

    public PaymentDeclinedException(String reason) {
        super("Payment declined: " + reason, null);
        this.reason = reason;
    }

    public String reason() { return reason; }
}

В адаптере — конкретный тип исключения, привязанный к реализации:

// sber-out-adapter/.../SberException.java
public class SberException extends PaymentPortException {
    public SberException(String msg, Throwable cause) { super(msg, cause); }
}

// sber-out-adapter/.../SberClientAdapter.java
@Component
public class SberClientAdapter implements PaymentPort {
    @Override
    public RegisterResult register(RegisterCommand cmd) {
        try {
            return /* ... */;
        } catch (FeignException e) {
            throw new SberException("Failed to register payment", e);
        }
    }
}

Handler в core ловит доменное исключение, не SberException — и не потому, что так договорились, а потому что не может: у core нет зависимости на модуль адаптера, и класс SberException ему просто не виден:

// core/.../CreatePaymentHandler.java — Spring-аннотаций в ядре нет
@RequiredArgsConstructor
class CreatePaymentHandler implements UseCaseHandler<CreatePaymentCommand, RegisterResult> {

    private final PaymentPort paymentPort;
    private final PaymentCommandMapper mapper;

    @Override
    public RegisterResult handle(CreatePaymentCommand cmd) {
        try {
            return paymentPort.register(mapper.toRegisterCommand(cmd));
        } catch (PaymentDeclinedException e) {
            throw new OrderCannotBePaidException(cmd.orderId(), e.reason());
        } catch (PaymentPortException e) {
            throw new PaymentSystemUnavailableException(e);
        }
    }
}

Порядок веток важен: PaymentDeclinedException — подкласс PaymentPortException, поэтому его ветка обязана стоять выше, иначе она никогда не сработает. И обратите внимание на типы: port объявлен как RegisterResult register(RegisterCommand cmd) — значит handler и принимает, и отдаёт ровно то, что записано в контракте порта, а команду из контроллера приводит к доменной через маппер.

Если завтра заменить SberClientAdapter на адаптер другой платёжки — handler не меняется. Он знает только о доменных исключениях.

Куда девается доменное исключение

«Обработчик ловит доменное исключение» — половина ответа; вторая половина про то, что с ним происходит дальше, и она важна, потому что здесь легко нарушить границу.

Путь исключения. Адаптер переводит техническую ошибку в доменную (или домен бросает своё правило) → исключение летит через обработчик, который его либо обрабатывает, либо пропускает → на входе его перехватывает обработчик ошибок входящего адаптера и превращает в ответ нужного формата.

Где это происходит. В коде входящего адаптера — там, где знают про формат ответа: для HTTP это класс-обработчик исключений (@RestControllerAdvice), для слушателя очереди — своя обработка, для задачи по расписанию — запись в журнал и метрика. Одно и то же доменное исключение в каждом случае обрабатывается по-своему, и именно поэтому оно не должно знать про HTTP.

// rest-api: единственное место, где домен встречается с кодами ответа
@RestControllerAdvice
class ErrorHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ResponseEntity<ProblemDetail> notFound(OrderNotFoundException e) {
        return problem(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", e.getMessage());
    }

    @ExceptionHandler(OrderAlreadyConfirmedException.class)
    ResponseEntity<ProblemDetail> conflict(OrderAlreadyConfirmedException e) {
        return problem(HttpStatus.CONFLICT, "ORDER_ALREADY_CONFIRMED", e.getMessage());
    }

    @ExceptionHandler(StorageUnavailableException.class)
    ResponseEntity<ProblemDetail> unavailable(StorageUnavailableException e) {
        return problem(HttpStatus.SERVICE_UNAVAILABLE, "TEMPORARY_FAILURE", "Повторите позже");
    }
}

Почему доменное исключение не наследует классы с кодами ответа. Соблазн понятный: написать class OrderNotFoundException extends ResponseStatusException(NOT_FOUND) — и обработчик ошибок не нужен. Три причины, по которым так не делают:

  1. Ядро начинает знать про HTTP. Импорт класса веб-фреймворка в ядре — нарушение правила зависимостей, и тест архитектуры это поймает.
  2. То же исключение нельзя использовать в другом входе. Слушатель очереди получит исключение с кодом 404, который там ничего не значит; задача по расписанию — тем более.
  3. Соответствие «исключение → код» перестаёт быть видимым. Разбросанное по классам исключений, оно не читается списком, и ответ на вопрос «какие коды отдаёт этот сервис» требует обхода всего кода.

Что несёт доменное исключение вместо кода: идентификатор объекта, текущее состояние, машинный код ошибки на языке предметной области (ORDER_ALREADY_CONFIRMED) и человекочитаемое сообщение. Кода ответа в нём нет — сопоставление живёт на входе. Подробный разбор формата ответа об ошибке и соответствия кодов — в разделе про обработку ошибок.

Inbound port — это UseCase

Вход в ядро тоже надо как-то описать: контроллер должен вызывать ядро, не зная его внутренностей. Классический ответ — ещё один интерфейс, inbound port. В UCP отдельный интерфейс не нужен: роль inbound-port'а играет связка UseCase + UseCaseHandler, а точкой входа служит UseCaseDispatcher.

// REST-контроллер (in-adapter)
@RestController
public class OrderController implements OrdersApi {
    private final UseCaseDispatcher dispatcher;

    @Override
    public ResponseEntity<OrderJson> createOrder(@Valid CreateOrderRequest req) {
        var cmd = mapper.toCommand(req);
        var order = dispatcher.dispatch(cmd);   // ← вход в core
        return ResponseEntity.created(...).body(mapper.toJson(order));
    }
}

Контроллер не зависит от конкретного handler'а. Он знает только UseCaseDispatcher, который сам маршрутизирует команду к нужному handler'у по типу. Это убирает лишние зависимости и оставляет контроллер тонким.

Подробнее о роли UseCase и handler'ов — в статье Use Case Pattern.

Частые ошибки

Port лежит в модуле адаптера. Port — это контракт от core к инфраструктуре, он должен быть в core/. Если положить его в адаптер, core не увидит интерфейс для внедрения зависимости — стрелка зависимостей развернётся в неправильную сторону.

Optional там, где отсутствие — это ошибка. Если handler ожидает, что объект обязательно существует, лучше бросить доменное исключение сразу, чем возвращать Optional и разворачивать его в каждом вызове:

// Когда отсутствие — нормальный кейс (query)
Optional<Order> findById(OrderId id);

// Когда отсутствие — ошибка (command)
Order findRequired(OrderId id);  // бросает OrderNotFoundException

Или handler сам решает:

Order order = orderRepository.findById(cmd.id())
    .orElseThrow(() -> new OrderNotFoundException(cmd.id()));

У <X>Repository есть ещё одна деталь контракта, про которую легко забыть: командная часть читает агрегат под блокировкой, поэтому в сигнатуре появляется второй параметр — findById(OrderId id, SelectMode mode). Запросу хватит обычного чтения, команде нужен SelectMode.FOR_UPDATE, иначе две одновременные транзакции затрут работу друг друга. Разбор — в статье Command side в CQRS.

Port как абстрактный класс, а не интерфейс. Port — это контракт, не структура. Абстрактный класс мешает подменять порт в тестах. Mockito и для интерфейса, и для класса генерирует подставной тип через ByteBuddy — механизм один и тот же, но с классом добавляются оговорки: final-класс и final-методы без отдельного inline mock maker не подменяются вообще, конструктор приходится обходить через Objenesis, а статическая инициализация родителя всё равно отработает. А ещё в Java нет множественного наследования классов: если адаптер уже наследует что-то другое, он не сможет реализовать port-класс.

// Правильно
public interface PaymentPort { ... }

// Неправильно
public abstract class PaymentPort { ... }

Коротко

  • Port — это интерфейс в core/<bc>/port/out/. Core описывает, что ему нужно; адаптер реализует. Имена: <X>Repository (агрегат), <X>ViewRepository (CQRS), <Y>Port (внешние системы), <Z>EventPublisher (события).
  • Методы port'а принимают и возвращают доменные типы, не структуры из SDK внешних систем. Исключения: базовый класс и доменные отказы — в core/<bc>/exception/, технологические подклассы — в адаптерах. Handler ловит доменное исключение.
  • Inbound port = UseCase + UseCaseDispatcher. Отдельный интерфейс не нужен.
  • Port всегда interface, не класс: легче подменять в тестах, нет ограничений на множественное наследование.
  • У витрины чтения отдельный порт, потому что у неё другие типы на выходе, другой размер и другая судьба: метод чтения в репозитории агрегата — начало размывания границы.
  • Две реализации одного порта: по настройке для разных окружений, по признаку применимости с выбором в ядре, или это два разных порта; приоритет по умолчанию — только на время перехода.
  • Порт описывает потребность ядра, а не возможности системы: два-четыре метода на связную потребность, реализация может быть одна на несколько портов, а признаки для разреза — больше шести методов и лишние заглушки в тестах.
  • Порт не нужен для времени, идентификаторов, журнала, настроек и чистых вычислений; признак перегиба — реализация лежит в том же модуле, что интерфейс, и никуда не ходит.
  • В тестах порт закрывают поддельной реализацией в памяти (для репозиториев), заглушкой с ответом (для внешних систем) и моком только там, где вызов и есть результат; живут они в тестовых исходниках ядра.
  • Доменное исключение не несёт кода ответа: соответствие «исключение → код» живёт в обработчике входящего адаптера, потому что у очереди и планировщика коды не значат ничего.

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

  • Adapters out — кто реализует port-интерфейс и как устроен out-адаптер.
  • Adapters in — как REST-контроллер использует UseCaseDispatcher как inbound-вход.
  • Use Case Pattern — про UseCaseDispatcher и роль handler'ов.
  • Repository pattern в jOOQ — конкретная реализация <X>Repository-port'а через jOOQ.