When an HTTP request, a Kafka message, or a CLI command reaches the service — what greets it? A dedicated layer that knows nothing about business logic and does only one thing: accept the incoming signal, translate it into the "language" of the core, and pass it on. This layer is called inbound adapters (in-adapters).
Why the adapter is a separate layer
Imagine you have an orders service. It gets called from three sides — the user via REST, another system via Kafka, an administrator via the console.
Without an adapter layer, each such "entry point" knows about the business logic directly. Change the rule for calculating the total, and you need to update the handling both in the REST controller, in the Kafka listener, and in the CLI. Miss one place — and you get divergent behavior.
With an adapter layer, each entry point does one thing: it turns its own "language" (HTTP, a Kafka event, command-line arguments) into a single command object that the core understands. The business rule lives in one place — in the core.
One entry type — one module
Different entry points are isolated into separate modules:
user-api-in-adapter/ # public REST for the end user
admin-api-in-adapter/ # REST for administrators
kafka-in-adapter/ # Kafka consumers
cli-in-adapter/ # console commands (if needed)
scheduler-in-adapter/ # @Scheduled tasks
Why this split? Three reasons.
Its own security per entry point. user-api-in-adapter accepts a JWT with a user audience. admin-api-in-adapter accepts a token with an administrator audience and possibly mutual TLS authentication. Each has its own SecurityFilterChain and its own configuration.
Its own API contract. The public OpenAPI file is for client commands and SDKs. The administrative one is for internal use only. There's no need to tag every endpoint as "public" or "hidden" and filter it out when generating documentation.
Compile-time isolation. If user-api-in-adapter and admin-api-in-adapter are separate modules, you cannot accidentally call an administrative handler from a public controller: the compiler doesn't see the class.
The controller implements a generated interface
In hexagonal architecture, the REST API contract is described in an OpenAPI specification, from which a Java interface is generated. The controller implements that interface rather than writing @RequestMapping annotations by hand.
@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(/* parameters */) {
var query = mapper.toQuery(/* ... */);
var orders = dispatcher.dispatch(query);
return ResponseEntity.ok(mapper.toJsonList(orders));
}
}
OrdersApi is an interface generated from orders-api.yaml. It already describes the routes, parameter validation, and the request and response types. The controller only implements the methods.
What this gives you:
- The contract lives in the specification. An API change starts by editing the yaml file, not by hunting for the right annotation in the code.
- Clients get an SDK from the same specification. Backend, Mobile, and Frontend — every consumer works from a single source of truth.
- The controller stays thin. Three or four lines per endpoint: map the input → dispatch → map the output.
The mapper translates between two worlds
Between the REST DTO (what arrived over HTTP) and the core command (what the core understands) sits a dedicated class — the RequestMapper. It lives in the in-adapter module and knows both formats.
@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) { /* ... */ }
}
The mapper is bidirectional: toCommand translates the request into a command, toJson translates the result back into a REST DTO. Knowledge of the REST format stays here and only here — the core knows nothing about it.
What the in-adapter knows, and what it doesn't
The in-adapter knows about the transport layer: Spring Web (@RestController, @RequestBody, @Valid), Jackson for JSON serialization, Jakarta Validation for checking DTOs.
The in-adapter knows nothing about other adapters:
- it doesn't import classes from
persistence/; - it doesn't know about
*-out-adapter/(the payment gateway, SMS, external APIs); user-api-in-adapterdoesn't see the classes ofadmin-api-in-adapter.
If two adapters need to coordinate — that's done by a use case in the core: the handler injects the required ports, and Spring supplies the implementations at startup.
Three common mistakes
Business logic in the controller
// How not to do it
@PostMapping("/orders")
public ResponseEntity<OrderJson> createOrder(@RequestBody CreateOrderRequest req) {
if (req.getTotalAmount() > 100_000) { // ← a business rule settled in the controller
return ResponseEntity.badRequest().build();
}
// ...
}
The problem: the very same rule is needed in the Kafka listener, in the CLI, and in the administrative API. If it lives in the controller — you have to copy it into every place. Miss one — and you get divergent behavior.
The rule moves into a domain method (Order.create(...) throws OrderTooLargeException) or into the command handler in the core.
Calling the repository directly from the controller
// How not to do it
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderRepository orderRepository; // ← the repository right in the controller
@PostMapping("/orders")
public ResponseEntity<OrderJson> createOrder(@RequestBody CreateOrderRequest req) {
Order order = Order.create(/* ... */);
orderRepository.save(order); // ← no handler
return ResponseEntity.ok(mapper.toJson(order));
}
}
What you lose with this approach:
- The transaction.
@Transactionalsits on the handler. Without it, everysaveis its own transaction. - Authorization. ABAC checks usually live on the handler. Bypassing it, you skip the permission check.
- Outbox. If you need to publish events when an order is created — that too is done in the handler.
The controller calls UseCaseDispatcher.dispatch(command) — a single entry point that guarantees all of these aspects.
Returning a domain object to the outside
// How not to do it
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) { // ← a domain entity in the response
return orderRepository.findById(id).orElseThrow();
}
If a domain object goes out to the world:
- Leaking internal structure. Fields that shouldn't be visible from outside (internal identifiers, PII) end up in the response.
- Jackson breaks the domain. To serialize
Order, you'll need a no-arg constructor, public setters, annotations — the object stops being a domain object. - Renaming a field = a broken API. A change to the domain entity immediately breaks the clients' contract.
The response is a REST DTO (OrderJson), mapped via OrderRequestMapper.toJson(order).
In short
- An inbound adapter accepts a request from the outside world (HTTP, Kafka, CLI), translates it into a core command, receives the result, and translates it back. No business logic — only transformation and routing.
- Each entry type is isolated into a separate gradle module: its own security, its own OpenAPI, compile-time isolation.
- The controller implements an interface generated from the OpenAPI specification — the contract lives in the yaml, not in the code.
- The mapper (
RequestMapper) is a dedicated class in the in-adapter, bidirectional: request → core command, result → REST DTO. - The in-adapter knows about Spring Web and Jackson. It doesn't know about the persistence module or the other adapters.
- Business logic in the controller is a straight path to duplication. Rules live in the core.
- The controller calls
UseCaseDispatcher.dispatch(command)— not the repository directly. - The response is always a REST DTO, never a domain object.
Further reading
- Hexagonal architecture — overview — how ports, the core, and adapters fit together overall.
- Adapters out — the symmetric side: outbound adapters to the database and external APIs.
- Use Case Pattern — how the
UseCaseDispatcherworks and why the handler is the right place for business logic.