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

Когда 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, не доменный объект.

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