Опирается на правила: R-HEX-AIN-1R-HEX-AIN-4 и R-HEX-AIN-X1R-HEX-AIN-X4 из Hexagonal Style Guide → раздел 5. Adapters in.

Важно знать

  • На каждый тип входа — отдельный *-in-adapter модуль: user-api-in-adapter, admin-api-in-adapter, kafka-in-adapter, cli-in-adapter.
  • Controller реализует generated <Tag>Api (из openapi-generator), не пишет handcrafted @RequestMapping.
  • Маппер <X>RequestMapper переводит REST-DTO ↔ Use Case command и REST-DTO ↔ domain. Отдельный класс в *-in-adapter.
  • In-adapter знает Spring Web + Jackson + Jakarta Validation. НЕ знает про другие адаптеры (persistence/, *-out-adapter/).
  • Бизнес-логика в Controller запрещена. Логика — в Handler в core/.
  • Controller дёргает UseCaseDispatcher, не Repository напрямую. Иначе теряются единая транзакция / authorization.
  • Controller возвращает REST-DTO, не domain entity. Внутреннее представление не утекает наружу.

In-adapter — это точка, через которую внешний мир (HTTP-клиент, Kafka-сообщение, CLI-команда) входит в сервис. Он принимает запрос, маппит его в Use Case command, отдаёт UseCaseDispatcher, получает результат, маппит обратно в HTTP-ответ. Никакой бизнес-логики, только трансформация и маршрутизация. Раскрытие правил R-HEX-AIN-* ниже.

Per-purpose модули

R-HEX-AIN-1: каждый тип входа — свой gradle-модуль.

user-api-in-adapter/        # публичный REST для конечного пользователя
admin-api-in-adapter/       # REST для админ-панели, отдельный SecurityFilterChain
kafka-in-adapter/           # Kafka consumers как entry-point
cli-in-adapter/             # CLI / batch (опционально)
scheduler-in-adapter/       # @Scheduled tasks (если планировщик решает «когда», но дёргает port'ы)

Что это даёт:

  • Per-purpose security. user-api-in-adapter принимает JWT с user-audience; admin-api-in-adapter — с admin-audience и mTLS. Каждый со своим SecurityFilterChain, отдельным application.yml-блоком.
  • Per-purpose OpenAPI. Публичный OpenAPI (для клиентских команд) и admin-OpenAPI (внутренний) — два независимых файла. Не нужно фильтровать «эти endpoints в публичную доку, а эти — нет».
  • Compile-time изоляция. Кто-то добавит в user-API handler с правами админа? Это два разных модуля, они не видят классы друг друга. Случайное расширение публичного API через «черный ход» невозможно.

См. также Структура модулей — общая раскладка multi-module gradle.

Controller реализует generated Api

R-HEX-AIN-2: контракт REST идёт через OpenAPI → openapi-generator → <Tag>Api-интерфейс → Controller implements.

@RestController
@RequiredArgsConstructor
public class OrderController implements OrdersApi {     // ← generated interface

    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(/* ...query params... */) {
        var query = mapper.toQuery(/* ... */);
        var orders = dispatcher.dispatch(query);
        return ResponseEntity.ok(mapper.toJsonList(orders));
    }
}

Что важно:

  • OrdersApi — это generated interface из orders-api.yaml через openapi-generator. В нём уже описаны @RequestMapping, валидация query-параметров, типы request/response DTO.
  • Controller не пишет @PostMapping("/orders") руками — это нарушение R-OAS-1 в REST API Style Guide. Контракт API живёт в spec, не в коде.
  • Response — REST-DTO, не domain entity. OrderJson — generated тип из той же openapi-spec.

Преимущества:

  • Контракт = spec. Изменение API начинается с правки yaml, не с кода. Spec гоняется через линтинг (spectral), и любые breaking change ловятся ещё до коммита.
  • Клиенты получают актуальный SDK. Из той же spec генерируется клиентский SDK для каждой потребляющей команды — Backend → Mobile → Frontend.
  • Контроллер становится тонким. 3–4 строки на endpoint: маппинг → dispatch → маппинг. Бизнес-логики в нём нет (см. ниже).

RequestMapper

R-HEX-AIN-3: отдельный класс в in-adapter, маппит REST-DTO ↔ Use Case + REST-DTO ↔ domain.

@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) { /* ... */ }
}

Что важно:

  • Plain Java (или MapStruct, если механический копир). См. Mapping record ↔ domain в jOOQ — те же соображения «MapStruct ок только когда без assemble-логики».
  • Маппер живёт в in-adapter, не в core. Знание о REST-DTO — это знание in-adapter.
  • ДвустороннийtoCommand (вход) и toJson (выход). Не пытаемся сделать один универсальный mapper.

Что in-adapter знает и не знает

R-HEX-AIN-4: in-adapter знает Spring + REST-стек, не знает про другие адаптеры.

Знает:

  • Spring Web (@RestController, @RequestBody, @Valid).
  • Jackson (для JSON-сериализации) — через автоконфиг Spring Boot.
  • Jakarta Validation — для валидации REST-DTO (см. Validation Style Guide).
  • Generated <Tag>Api-интерфейсы из openapi-generator.
  • UseCaseDispatcher из usecase-pattern-библиотеки.

НЕ знает:

  • persistence/ — у in-adapter нет dependency на этот модуль в gradle.
  • *-out-adapter/ — не знает про Sber, OdnaKassa, SMS.
  • Другие in-adapter'ы — user-api-in-adapter не знает про admin-api-in-adapter.

Это и есть «адаптеры не знают друг друга» — R-HEX-AIN-X4 и R-HEX-AOUT-X4. Координация двух адаптеров — это use case в core (handler инжектит оба port'а).

Что запрещено

R-HEX-AIN-X1: бизнес-логика в Controller.

// ПЛОХО
@PostMapping("/orders")
public ResponseEntity<OrderJson> createOrder(@RequestBody CreateOrderRequest req) {
    if (req.getTotalAmount() > 100_000) {                  // ← бизнес-правило в controller
        return ResponseEntity.badRequest().build();
    }
    if (req.getItems().isEmpty()) {                        // ← инвариант
        throw new IllegalArgumentException("...");
    }
    // ...
}

Что не так:

  • Логика разъезжается. То же правило «sum < 100K» нужно в Kafka-listener'е, в admin-CLI, в @Scheduled bulk-импорте. Если оно в Controller — копируется руками.
  • Тесты только через @WebMvcTest. Невозможно протестировать «правило 100K» unit-тестом — нужно поднимать MockMvc.
  • Анемичный domain. Логика в Controller обычно идёт рука об руку с anemic-моделью в core. Это разрушает hex полностью.

Логика в Domain method (Order.create(...) бросает OrderTooLargeException) или в CommandHandler (если правило не на агрегате). См. Core слой про rich domain.

R-HEX-AIN-X2: Controller вызывает <X>Repository напрямую.

// ПЛОХО
@RestController
@RequiredArgsConstructor
public class OrderController {
    private final OrderRepository orderRepository;         // ← repository прямо в controller
    private final OrderDomainRecordMapper mapper;

    @PostMapping("/orders")
    public ResponseEntity<OrderJson> createOrder(@RequestBody CreateOrderRequest req) {
        Order order = Order.create(/* ... */);
        orderRepository.save(order);                       // ← минуя handler
        return ResponseEntity.ok(mapper.toJson(order));
    }
}

Что теряется:

  • Транзакция. @Transactional живёт на handler'е (см. Транзакции в jOOQ). Без handler'а transactional границы размазываются — каждый repository.save в своей транзакции.
  • Authorization. ABAC-проверки (@PreAuthorize или вызов @Component("access")-бина) обычно живут на handler'е. Минуя его — обходим авторизацию.
  • Outbox. Если в командной части нужно публиковать события (OrderCreatedEvent через outbox), это делается в handler'е. Controller не должен знать про outbox.

Controller дёргает UseCaseDispatcher.dispatch(command) — единая точка для всех аспектов.

R-HEX-AIN-X3: Controller возвращает domain entity наружу.

// ПЛОХО
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {              // ← domain entity напрямую
    return orderRepository.findById(/* ... */).orElseThrow();
}

Что не так:

  • Утечка внутренней структуры. Domain Order может иметь поля, которые наружу не должны попадать (PII, internal IDs, audit info). Без явного DTO они утекут.
  • Jackson-сериализация ломает domain. Чтобы Jackson умел сериализовать Order, понадобится no-arg constructor, public setters, аннотации @JsonProperty — domain превращается в JSON-friendly POJO, теряя rich-методы.
  • Breaking change в domain ломает API. Переименовали поле в Order — API контракт развалился. Между domain и API должен быть mapper, держащий контракт.

Возвращай REST-DTO (OrderJson), маппируй через OrderRequestMapper.toJson(order).

R-HEX-AIN-X4: in-adapter зависит от out-adapter — нарушение симметрии. Адаптеры зависят от core/, не друг от друга. Если controller'у нужен прямой вызов Sber'а — он зовёт paymentPort.register(...) в handler'е, который инжектится через core; Sber-out-adapter подкладывается DI на старте.

Куда дальше