← Back to the section

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-adapter doesn't see the classes of admin-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. @Transactional sits on the handler. Without it, every save is 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