Когда 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/ # команды из консоли (если нужно)
scheduler-in-adapter/ # @Scheduled-задачи
Зачем такое разделение? Три причины.
Своя безопасность на каждый вход. user-api-in-adapter принимает JWT с пользовательской аудиторией. admin-api-in-adapter — токен с аудиторией администратора и, возможно, взаимную TLS-аутентификацию. У каждого свой SecurityFilterChain и своя конфигурация.
Свой API-контракт. Публичный OpenAPI-файл — для клиентских команд и SDK. Административный — только для внутреннего использования. Не нужно на каждом эндпоинте ставить метки «публичный» или «скрытый» и фильтровать при генерации документации.
Изоляция во время компиляции. Если user-api-in-adapter и admin-api-in-adapter — разные модули, нельзя случайно позвать административный обработчик из публичного контроллера: компилятор не видит нужного класса.
Контроллер реализует сгенерированный интерфейс
В гексагональной архитектуре контракт REST-API описывается в OpenAPI-спецификации, из которой генерируется Java-интерфейс. Контроллер этот интерфейс реализует, а не пишет аннотации @RequestMapping руками.
@RestController
@RequiredArgsConstructor
public class OrderController implements OrdersApi { // ← сгенерированный интерфейс
private final UseCaseDispatcher dispatcher;
private final OrderRequestMapper mapper;
@Override
public ResponseEntity<OrderJson> createOrder(@Valid CreateOrderRequest req) {
var cmd = mapper.toCommand(req);
var order = dispatcher.dispatch(cmd);
return ResponseEntity
.created(URI.create("/orders/" + order.getId()))
.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. В нём уже описаны маршруты, валидация параметров, типы запросов и ответов. Контроллер только реализует методы.
Что это даёт:
- Контракт живёт в спецификации. Изменение 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-формате остаётся только здесь, ядро о нём ничего не знает.
Что 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 подкладывает реализации при старте.
Три распространённые ошибки
Бизнес-логика в контроллере
// Как делать не нужно
@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и почему обработчик — правильная точка для бизнес-логики.