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

Когда HTTP-запрос, Kafka-сообщение или CLI-команда попадает в сервис — что их встречает? Специальный слой, который не знает о бизнес-логике и занимается только одним: принять входящий сигнал, перевести его на «язык» ядра и передать дальше. Этот слой называют входными адаптерами (in-adapters).

одна команда HTTP REST-контроллер Kafka слушатель темы CLI разбор аргументов ядро через диспетчер

Три разных входа сводятся к одной команде, и ядро получает её одинаково, кто бы ни постучался.

Почему адаптер — отдельный слой

Представьте: у вас есть сервис заказов. Его вызывают с трёх сторон — пользователь через REST, система через Kafka, администратор через консоль.

Без адаптерного слоя каждый такой «вход» знает о бизнес-логике напрямую. Поменяли правило расчёта суммы — нужно обновить обработчик и в REST-контроллере, и в Kafka-листенере, и в CLI. Пропустить одно место — значит получить расхождение в поведении.

С адаптерным слоем каждый вход делает одно: превращает свой «язык» (HTTP, Kafka-событие, аргументы командной строки) в единый объект команды, который понимает ядро. Бизнес-правило живёт в одном месте — в ядре.

Один тип входа — один модуль

Разные входы изолируются в отдельные модули:

Модуль входаЧто принимает
user-api-in-adapter/публичный REST для конечного пользователя
admin-api-in-adapter/REST для администраторов
kafka-in-adapter/Kafka consumers
cli-in-adapter/команды из консоли (если нужно)

Планировщика в этой таблице нет намеренно. По потоку управления @Scheduled-задача — тоже вход: она сама запускает сценарий, её никто не зовёт снаружи. Но в стандарте проекта модуль называется scheduler-out-adapter/ и лежит рядом с исходящими: у него нет ни внешнего контракта, ни своей проверки прав, ни DTO — только расписание и вызов UseCaseDispatcher. Имя спорное, важно другое: во всём проекте оно должно быть одно, иначе в settings.gradle.kts появятся два модуля-близнеца.

Зачем такое разделение? Три причины.

Своя безопасность на каждый вход. user-api-in-adapter принимает JWT с пользовательской аудиторией. admin-api-in-adapter — токен с аудиторией администратора и, возможно, взаимную TLS-аутентификацию. У каждого свой SecurityFilterChain и своя конфигурация.

Свой API-контракт. Публичный OpenAPI-файл — для клиентских команд и SDK. Административный — только для внутреннего использования. Не нужно на каждом эндпоинте ставить метки «публичный» или «скрытый» и фильтровать при генерации документации.

Изоляция во время компиляции. Если user-api-in-adapter и admin-api-in-adapter — разные модули, нельзя случайно позвать административный обработчик из публичного контроллера: компилятор не видит нужного класса.

Вход из очереди: чем отличается от REST

Заголовок обещает три вида входа, а показан один. У слушателя очереди своя механика, и вопросы, которых у HTTP нет вовсе, — разберём их, потому что каждый решается именно в адаптере.

// kafka-in-adapter
@Component
@RequiredArgsConstructor
class PaymentEventsListener {

    private final UseCaseDispatcher dispatcher;
    private final PaymentEventMapper mapper;

    @KafkaListener(topics = "payments.events", groupId = "orders-service")
    void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
        PaymentReceivedEvent event;
        try {
            event = mapper.parse(record.value());        // разбор — забота адаптера
        } catch (MalformedEventException e) {
            log.error("Неразбираемое сообщение, смещение {}: {}", record.offset(), e.getMessage());
            ack.acknowledge();                            // подтверждаем, иначе встанем навсегда
            deadLetters.send(record, e);                  // и уносим в отдельную тему
            return;
        }

        dispatcher.dispatch(mapper.toCommand(event));     // вход в ядро — как у контроллера
        ack.acknowledge();                                // подтверждаем ПОСЛЕ успешной обработки
    }
}

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

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

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

Где заканчивается транзакция. У HTTP ответ отправляется после фиксации, и это естественно. У очереди порядок такой: обработка в транзакции базы → фиксация → подтверждение смещения. Если подтвердить до фиксации, потеря сообщения при сбое; если сделать подтверждение частью транзакции базы — так нельзя, это две разные системы. Отсюда неизбежность повторов: между фиксацией и подтверждением есть окно, и сообщение, обработанное в нём, приедет второй раз. Это и есть причина, по которой идемпотентность не опция.

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

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

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

Контроллер реализует сгенерированный интерфейс

Аннотации @RequestMapping, написанные руками, расходятся со спекой незаметно: клиенты генерируют SDK по устаревшему yaml, и никто не замечает, пока не сломается интеграция. Поэтому в гексагональной архитектуре контракт REST-API описывается в OpenAPI-спецификации, из которой генерируется Java-интерфейс, а контроллер этот интерфейс реализует.

@RestController
@RequiredArgsConstructor
public class OrderController implements OrdersApi {     // ← сгенерированный интерфейс

    private final UseCaseDispatcher dispatcher;
    private final OrderRequestMapper mapper;

    @Override
    public ResponseEntity<OrderJson> createOrder(CreateOrderRequest req) {
        var cmd = mapper.toCommand(req);
        var order = dispatcher.dispatch(cmd);
        return ResponseEntity
            .created(URI.create("/orders/" + order.id().value()))
            .body(mapper.toJson(order));
    }

    @Override
    public ResponseEntity<List<OrderJson>> findOrders(/* параметры */) {
        var query = mapper.toQuery(/* ... */);
        var orders = dispatcher.dispatch(query);
        return ResponseEntity.ok(mapper.toJsonList(orders));
    }
}

OrdersApi — интерфейс, сгенерированный из orders-api.yaml. В нём уже описаны маршруты, валидация параметров, типы запросов и ответов. Контроллер только реализует методы.

Отдельно про @Valid: в реализации его нет, и это не оплошность. Генератор ставит @RequestBody и @Valid на параметр метода интерфейса, а Spring при разборе обработчика собирает аннотации параметра со всех интерфейсов, где этот метод объявлен, и складывает их с теми, что стоят на реализации. Дублировать @Valid в контроллере не нужно — проверка и так отработает.

Ещё одна деталь того же листинга — dispatcher.dispatch(cmd) возвращает готовый Order, хотя нигде не написано, какой у метода тип. Диспетчер объявлен обобщённым:

public interface UseCaseDispatcher {
    <R> R dispatch(UseCase<R> useCase);
}

Тип результата берётся из самой команды: CreateOrderCommand implements UseCase<Order> — значит var order компилятор выведет как Order, а FindOrdersQuery implements UseCase<List<Order>> даст List<Order>. Никаких приведений типов в контроллере не нужно.

Что это даёт:

  • Контракт живёт в спецификации. Изменение API начинается с правки yaml-файла, а не поиска нужной аннотации в коде.
  • Клиенты получают SDK из той же спецификации. Backend, Mobile и Frontend — все потребители работают с одним источником правды.
  • Контроллер остаётся тонким. На каждый эндпоинт — три-четыре строки: маппинг входа → dispatch → маппинг выхода.

Маппер переводит между двумя мирами

Между REST-DTO (тем, что пришло по HTTP) и командой ядра (тем, что ядро понимает) стоит отдельный класс — RequestMapper. Он живёт в модуле in-adapter и знает оба формата.

@Component
public class OrderRequestMapper {

    public CreateOrderCommand toCommand(CreateOrderRequest req) {
        return new CreateOrderCommand(
            new CustomerId(req.getCustomerId()),
            req.getItems().stream().map(this::toItem).toList(),
            Money.of(req.getTotalAmount(), Currency.RUB)
        );
    }

    public OrderJson toJson(Order order) {
        var json = new OrderJson();
        json.setId(order.id().value());
        json.setStatus(order.status().name());
        json.setTotalAmount(order.totalAmount().amount());
        return json;
    }

    public List<OrderJson> toJsonList(List<Order> orders) {
        return orders.stream().map(this::toJson).toList();
    }

    private OrderItemCommand toItem(OrderItemRequest req) { /* ... */ }
}

Маппер — двусторонний: toCommand переводит запрос в команду, toJson переводит результат обратно в REST-DTO. Знание о REST-формате остаётся только здесь, ядро о нём ничего не знает.

Права на входе и кто вызывает

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

Что делает адаптер. Всё, что относится к транспорту и к личности вызывающего:

  • Аутентификация. Проверка подписи токена, срока действия, аудитории; отказ 401, если токена нет или он неверен. Ядро про токены не знает вовсе.
  • Грубая проверка роли на точке входа. «Эту ручку может вызывать только администратор» — это свойство ручки, а не предметной области: @PreAuthorize("hasRole('ADMIN')") на методе контроллера, отказ 403. Такая проверка отсекает целые входы и естественно живёт там же, где сам вход.
  • Защита транспорта: ограничение частоты, размер запроса, проверка источника, взаимная проверка сертификатов для служебных входов.

Что делает ядро. Всё, что зависит от данных:

  • «Этот заказ принадлежит этому покупателю» — доменное правило, и его нельзя выразить на уровне ручки.
  • «Отменить может автор или сотрудник поддержки с уровнем 2» — то же.
  • «Сумма выше лимита требует подтверждения второго сотрудника» — тем более.

Граница простая: можно ли ответить, не заглядывая в данные. Да — адаптер; нет — ядро.

Как вызывающий попадает в команду

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

// core: ядро знает про вызывающего на своём языке
public record Caller(UserId userId, Set<Permission> permissions, CallerKind kind) {
    public boolean canActOnBehalfOf(CustomerId customer) { ... }
}

// rest-api: адаптер переводит контекст безопасности в доменное понятие
@Component
class CallerFactory {
    Caller from(Authentication authentication) {
        var jwt = (Jwt) authentication.getPrincipal();
        return new Caller(new UserId(jwt.getSubject()),
                          mapPermissions(jwt.getClaimAsStringList("scope")),
                          CallerKind.END_USER);
    }
}

// rest-api: контроллер кладёт вызывающего в команду
@PostMapping("/orders/{id}/cancel")
ResponseEntity<Void> cancel(@PathVariable UUID id, Authentication auth) {
    dispatcher.dispatch(new CancelOrderCommand(new OrderId(id), callerFactory.from(auth)));
    return ResponseEntity.noContent().build();
}

Что здесь важно по частям:

  • Вызывающий — часть команды, а не «контекст, который где-то есть». Тогда сценарий тестируется без всякой инфраструктуры: передали Caller с нужными правами и проверили решение.
  • Преобразование живёт в адаптере. Ядро не знает ни про токены, ни про имена разрешений во внешней системе прав: адаптер переводит чужие названия в свои.
  • Один тип для всех входов. Слушатель очереди собирает Caller вида «система», задача по расписанию — «планировщик». Тогда ядро одинаково обрабатывает любой источник, а в журнале аудита видно, кто на самом деле действовал.
  • Не тащить в ядро объект фреймворка. Соблазн передать Authentication или Jwt прямо в команду велик и ломает границу: ядро начнёт зависеть от библиотеки безопасности, и тесты потребуют её настройки.

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

Постраничная навигация и фильтры на входе

В примере параметры findOrders(/* параметры */) оставлены комментарием — а именно на них и ломается «тонкий контроллер». Разберём, что здесь работа адаптера, а что нет.

Работа адаптера — перевести параметры запроса в понятия ядра:

@GetMapping("/orders")
ResponseEntity<OrderPageJson> findOrders(
        @RequestParam(required = false) String status,
        @RequestParam(required = false) @DateTimeFormat(iso = DATE) LocalDate from,
        @RequestParam(defaultValue = "0") @Min(0) int page,
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,     // предел здесь
        @RequestParam(defaultValue = "createdAt,desc") String sort,
        Authentication auth) {

    var query = new FindOrdersQuery(
            mapper.toStatus(status),                 // строка -> доменное перечисление
            from,
            new PageRequest(page, size, mapper.toSort(sort)),   // своё понятие, не фреймворка
            callerFactory.from(auth));

    var result = dispatcher.dispatch(query);
    return ResponseEntity.ok(mapper.toJson(result));
}

Четыре решения, которые здесь приняты, и все они правильные:

  1. Предел размера страницы — в адаптере. @Max(100) на входе: запрос на миллион строк не должен доходить до ядра. Это защита транспорта, а не правило домена.
  2. Своё понятие страницы, а не тип фреймворка. Ядро принимает PageRequest из своих типов; если передать тип библиотеки, ядро начнёт от неё зависеть, а тесты — тянуть её в путь сборки. То же для результата: ядро возвращает свою запись со списком и признаком «есть ещё», а не тип фреймворка.
  3. Сортировка — из белого списка. mapper.toSort принимает только известные поля и бросает понятную ошибку на неизвестном: иначе клиент отсортирует по неиндексированной колонке или по полю, которого нет.
  4. Неверное значение перечисления — ошибка входа, а не домена. Строка status=whatever отсеивается преобразователем с ответом 400; в ядро попадают только допустимые значения.

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

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

Ответу нужны данные, которых нет в агрегате

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

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

Что делают вместо, по возрастанию цены:

  1. Сценарий чтения возвращает всё, что нужно ответу. Самый простой и правильный ответ: обработчик запроса читает витрину или делает один запрос с объединением и отдаёт готовую структуру с именем покупателя. Контроллер только преобразует её в формат ответа. Если имя лежит в той же базе — это один запрос, и никакой проблемы нет.
  2. Нужное поле копируется в свою модель через событие. Имя покупателя хранится рядом с заказом (в витрине или даже в самой записи), обновляется по событию из контекста покупателей. Тогда чтение — один запрос, отставание — доли секунды, а отказ соседнего сервиса не ломает отображение заказов. Это стандартный приём, и он же решает вопрос «что показывать, если сосед недоступен».
  3. Порт к соседнему контексту, вызываемый сценарием. Обработчик запроса получает данные через исходящий порт (реализация — вызов соседа или чтение его витрины) и собирает ответ. Ядро остаётся хозяином сборки, адаптер — тонким. Цена: отказ соседа теперь влияет на ваш ответ, поэтому нужны срок ожидания и поведение при отказе (показать без имени, а не упасть).
  4. Сборка на границе — но не в контроллере, а в отдельном сценарии. Если ответ действительно склеивается из нескольких источников с логикой («показать имя, а при его отсутствии — телефон»), это отдельный сценарий чтения в ядре, у которого есть имя и тесты.

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

Что in-adapter знает, а чего не знает

In-adapter знает про транспортный слой: Spring Web (@RestController, @RequestBody, @Valid), Jackson для JSON-сериализации, Jakarta Validation для проверки DTO.

In-adapter не знает ничего про другие адаптеры:

  • он не импортирует классы из persistence/;
  • он не знает про *-out-adapter/ (платёжный шлюз, SMS, внешние API);
  • user-api-in-adapter не видит классы admin-api-in-adapter.

Если двум адаптерам нужно скоординироваться — это делает use case в ядре: handler инжектит нужные порты, а Spring подкладывает реализации при старте.

Куда попадает исключение из ядра

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

Где он живёт. Во входящем адаптере, а не в модуле сборки и не в ядре. Причина: соответствие «исключение → код ответа» — часть контракта этого входа. У публичного и административного API разные форматы ответа об ошибке и разная подробность (публичному не показывают внутренние причины), у слушателя очереди кодов нет вовсе. Один общий обработчик на все входы означает, что либо форматы одинаковые, либо в нём появляются условия «если это публичный вход».

Что он делает, по категориям:

  • Нарушение бизнес-правила (OrderAlreadyConfirmed) → 409 или 422 с машинным кодом и сообщением.
  • Объект не найден → 404. Отдельно: если факт существования — тоже информация, чужой объект отдают как 404, а не 403.
  • Нет прав по данным → 403, если сам факт не секретен.
  • Неверный вход (не прошла проверка формата) → 400 со списком полей; это вообще не доходит до ядра.
  • Инфраструктура недоступна (StorageUnavailable) → 503 с предложением повторить, без подробностей наружу.
  • Всё остальное → 500, и в ответе ничего, кроме идентификатора ошибки; подробности — в журнал.

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

Чего в обработчике быть не должно: бизнес-логики («если заказ отменён, то попробуем ещё раз»), обращений к базе и решений предметной области. Он переводит, а не решает. Подробный разбор формата и кодов — в разделе про обработку ошибок.

Три распространённые ошибки

Бизнес-логика в контроллере

// Как делать не нужно
@PostMapping("/orders")
public ResponseEntity<OrderJson> createOrder(@RequestBody CreateOrderRequest req) {
    if (req.getTotalAmount() > 100_000) {       // ← бизнес-правило осело в контроллере
        return ResponseEntity.badRequest().build();
    }
    // ...
}

Проблема: одно и то же правило нужно и в Kafka-листенере, и в CLI, и в административном API. Если оно живёт в контроллере — нужно скопировать в каждое место. Пропустить одно — получить разное поведение.

Правило переносится в доменный метод (Order.create(...) бросает OrderTooLargeException) или в обработчик команды в ядре.

Прямой вызов репозитория из контроллера

// Как делать не нужно
@RestController
@RequiredArgsConstructor
public class OrderController {
    private final OrderRepository orderRepository;   // ← репозиторий прямо в контроллере

    @PostMapping("/orders")
    public ResponseEntity<OrderJson> createOrder(@RequestBody CreateOrderRequest req) {
        Order order = Order.create(/* ... */);
        orderRepository.save(order);                 // ← без обработчика
        return ResponseEntity.ok(mapper.toJson(order));
    }
}

Что теряется при таком подходе:

  • Транзакция. @Transactional стоит на обработчике, и он задаёт границу сценария целиком. Пока запись одна, отдельная транзакция на save ничем не хуже. Беда начинается со второй: сохранили заказ, пошли списывать остаток со склада, упали — заказ уже в базе, остаток нет, и откатывать нечем, потому что общей транзакции никогда не было.
  • Авторизация. ABAC-проверки обычно живут на обработчике. Минуя его, обходим проверку прав.
  • Outbox. Если нужно публиковать события при создании заказа — это тоже делается в обработчике.

Контроллер дёргает UseCaseDispatcher.dispatch(command) — единую точку входа, которая обеспечивает все эти аспекты.

Возврат доменного объекта наружу

// Как делать не нужно
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {    // ← доменная сущность в ответе
    return orderRepository.findById(id).orElseThrow();
}

Если наружу выходит доменный объект:

  • Утечка внутренней структуры. Поля, которые не должны быть видны снаружи (внутренние идентификаторы, PII), окажутся в ответе.
  • Jackson ломает домен. Чтобы сериализовать Order, понадобятся no-arg конструктор, публичные сеттеры, аннотации — объект перестаёт быть доменным.
  • Переименование поля = сломанный API. Изменение доменной сущности сразу ломает контракт клиентов.

Ответ — REST-DTO (OrderJson), маппинг через OrderRequestMapper.toJson(order).

Коротко

  • Входной адаптер принимает запрос из внешнего мира (HTTP, Kafka, CLI), переводит в команду ядра, получает результат и переводит обратно. Никакой бизнес-логики — только трансформация и маршрутизация. Каждый тип входа изолируется в отдельный gradle-модуль: своя безопасность, свой OpenAPI, compile-time изоляция.
  • Контроллер реализует сгенерированный из OpenAPI-спецификации интерфейс — контракт живёт в yaml, не в коде. Маппер (RequestMapper) — отдельный класс в in-adapter, двусторонний: запрос → команда ядра, результат → REST-DTO.
  • In-adapter знает про Spring Web и Jackson. Он не знает про persistence-модуль и другие адаптеры. Бизнес-логика в контроллере — прямой путь к дублированию. Правила живут в ядре.
  • Контроллер вызывает UseCaseDispatcher.dispatch(command) — не репозиторий напрямую. Ответ всегда REST-DTO, не доменный объект.
  • У входа из очереди свои вопросы: подтверждение смещения вручную и после обработки, обязательная идемпотентность, неразбираемое сообщение подтверждают и уносят в недоставленные, а окно между фиксацией и подтверждением делает повторы неизбежными.
  • Адаптеру остаются аутентификация, грубая роль на точке входа и защита транспорта; ядру — всё, что зависит от данных; граница — можно ли ответить, не заглядывая в данные.
  • Вызывающий приходит в команду как своё доменное понятие, собранное адаптером из контекста безопасности; читать контекст внутри ядра статически нельзя — сценарий перестаёт быть функцией от входа.
  • Исключение из ядра перехватывает обработчик ошибок того же входа: свои коды по категориям, идентификатор запроса в ответе, единый формат тела и никакой логики внутри.
  • На входе адаптер ограничивает размер страницы, переводит параметры в свои типы, берёт сортировку из белого списка и отсеивает неверные значения; выборку и решение про смещение или курсор делает сторона чтения.
  • Данные для ответа, которых нет в агрегате, собирает сценарий чтения, копия факта через событие или порт к соседу — но не контроллер вторым вызовом.

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

  • Гексагональная архитектура — обзор — как устроены порты, ядро и адаптеры в целом.
  • Adapters out — симметричная сторона: исходящие адаптеры к базе данных и внешним API.
  • Use Case Pattern — как устроен UseCaseDispatcher и почему обработчик — правильная точка для бизнес-логики.
  • Структура модулей - почему каждый тип входа выносят в отдельный gradle-модуль и как это закрепляет сборка.