Когда HTTP-запрос, Kafka-сообщение или CLI-команда попадает в сервис — что их встречает? Специальный слой, который не знает о бизнес-логике и занимается только одним: принять входящий сигнал, перевести его на «язык» ядра и передать дальше. Этот слой называют входными адаптерами (in-adapters).
Три разных входа сводятся к одной команде, и ядро получает её одинаково, кто бы ни постучался.
Почему адаптер — отдельный слой
Представьте: у вас есть сервис заказов. Его вызывают с трёх сторон — пользователь через 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));
}
Четыре решения, которые здесь приняты, и все они правильные:
- Предел размера страницы — в адаптере.
@Max(100)на входе: запрос на миллион строк не должен доходить до ядра. Это защита транспорта, а не правило домена. - Своё понятие страницы, а не тип фреймворка. Ядро принимает
PageRequestиз своих типов; если передать тип библиотеки, ядро начнёт от неё зависеть, а тесты — тянуть её в путь сборки. То же для результата: ядро возвращает свою запись со списком и признаком «есть ещё», а не тип фреймворка. - Сортировка — из белого списка.
mapper.toSortпринимает только известные поля и бросает понятную ошибку на неизвестном: иначе клиент отсортирует по неиндексированной колонке или по полю, которого нет. - Неверное значение перечисления — ошибка входа, а не домена. Строка
status=whateverотсеивается преобразователем с ответом400; в ядро попадают только допустимые значения.
Чего адаптер не делает: не считает страницы, не собирает условия запроса, не решает, что фильтровать. Он переводит и проверяет формат; выборка и её ограничения — работа стороны чтения, и там же живёт решение про смещение или курсор — разбор в статье про сторону запросов.
Заголовки и формат ответа — тоже адаптер. Общее число, ссылки на следующую страницу, признак «есть ещё» кладут в тело или в заголовки по соглашению вашего API; ядро об этом соглашении не знает. И если общее число дорого считать, решение «не отдаём его» принимается на уровне контракта API — то есть в адаптере, вместе с командой стороны чтения.
Ответу нужны данные, которых нет в агрегате
Частая ситуация: в ответе надо показать имя покупателя, а в заказе лежит только его идентификатор — покупатели живут в другом контексте или вообще в другом сервисе. Соблазн очевидный: контроллер делает второй вызов и склеивает ответ сам. Это и есть тот антипаттерн, о котором стоит сказать явно.
Почему нельзя склеивать в контроллере. Адаптер начинает знать, из чего состоит ответ по смыслу, и принимать решения предметной области («если имени нет, показать «Покупатель удалён»»); он получает вторую зависимость и вторую точку отказа; логика сборки ответа размножается по контроллерам, и два эндпоинта собирают «то же самое» по-разному.
Что делают вместо, по возрастанию цены:
- Сценарий чтения возвращает всё, что нужно ответу. Самый простой и правильный ответ: обработчик запроса читает витрину или делает один запрос с объединением и отдаёт готовую структуру с именем покупателя. Контроллер только преобразует её в формат ответа. Если имя лежит в той же базе — это один запрос, и никакой проблемы нет.
- Нужное поле копируется в свою модель через событие. Имя покупателя хранится рядом с заказом (в витрине или даже в самой записи), обновляется по событию из контекста покупателей. Тогда чтение — один запрос, отставание — доли секунды, а отказ соседнего сервиса не ломает отображение заказов. Это стандартный приём, и он же решает вопрос «что показывать, если сосед недоступен».
- Порт к соседнему контексту, вызываемый сценарием. Обработчик запроса получает данные через исходящий порт (реализация — вызов соседа или чтение его витрины) и собирает ответ. Ядро остаётся хозяином сборки, адаптер — тонким. Цена: отказ соседа теперь влияет на ваш ответ, поэтому нужны срок ожидания и поведение при отказе (показать без имени, а не упасть).
- Сборка на границе — но не в контроллере, а в отдельном сценарии. Если ответ действительно склеивается из нескольких источников с логикой («показать имя, а при его отсутствии — телефон»), это отдельный сценарий чтения в ядре, у которого есть имя и тесты.
Ориентир выбора: данные в той же базе — первый вариант; нужны часто и меняются редко — второй; нужны редко и должны быть свежими — третий. Контроллер, делающий два вызова и склеивающий результат, не входит ни в один из вариантов.
Что 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-модуль и как это закрепляет сборка.