Опирается на правила:
R-HEX-AIN-1…R-HEX-AIN-4иR-HEX-AIN-X1…R-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 на старте.
Куда дальше
- Hexagonal Style Guide → раздел 5. Adapters in — нормативные формулировки.
- Adapters out — симметричная сторона для исходящих интеграций.
- REST API Style Guide — про OpenAPI как контракт и generated
<Tag>Api. - Validation Style Guide — Jakarta Validation на REST-DTO в in-adapter'е.
- Use Case Pattern — про
UseCaseDispatcher.