When a service wants to save data to a database, send an SMS, or call a third-party API, it needs a "hand" that reaches out and does it. In Hexagonal Architecture these "hands" are called outbound adapters. Let's look at what they are and how to build them correctly.
The problem: the code knows too much
Imagine the payment business logic calling the Sber API directly:
class PaymentService {
private final SberOrderServicesApi sberApi; // Sber client right inside the business code
public void register(Order order) {
var req = new SberRegisterRequest();
req.setAmount(order.amount() * 100); // in kopecks — Sber-specific
sberApi.register(req, null);
}
}
What's wrong here:
- To switch the payment system, you have to rewrite the business logic.
- To write a test, you have to spin up a Sber API mock inside a test that's about business rules.
- Sber-specific details (kopecks, numeric currency codes) leak all over the code.
Hexagonal Architecture answers this: let the business logic know only an abstraction (PaymentPort), while the specifics — who Sber is and how to talk to it — live in a separate module. That module is the outbound adapter.
What an outbound adapter is
An outbound adapter is an implementation of a port interface that the core declared as "I need an external dependency."
The layout:
core/ → declares PaymentPort (interface)
sber-out-adapter/ → implements PaymentPort (knows about the Sber API)
The core works only with PaymentPort. It doesn't care who stands behind it — Sber, OdnaKassa, or a mock in a test. That's the essence of isolation.
One module — one external system
Each external system gets its own gradle module:
persistence/ # database (PostgreSQL via jOOQ)
sber-out-adapter/ # Sber payment system
sms-out-adapter/ # SMS provider
kafka-out-adapter/ # publishing events to Kafka
s3-out-adapter/ # file storage
scheduler-out-adapter/ # task scheduler
Why not one shared "everything external" module:
Dependency isolation. sber-out-adapter pulls in the Sber SDK. sms-out-adapter pulls in your SMS provider's SDK. If you switch SMS providers tomorrow, you touch one module — the rest are not rebuilt.
Isolated resilience configuration. Circuit Breaker, timeout, and retry policy are configured separately for each system. A shared HTTP client for all outbound traffic means a Sber outage can slow down SMS delivery.
Isolated metrics. The payment_sber_* and sms_smsc_* metrics are different. Mixing them in one module is inconvenient and misleading.
Isolated tests. WireMock for Sber runs in the sber-out-adapter tests, WireMock for SMS runs in the sms-out-adapter tests. Not one giant mock for everything.
What an adapter looks like
// sber-out-adapter/.../SberClientAdapter.java
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort { // implements — interface from core/
private final SberOrderServicesApi sberApi; // Sber client
private final SberMapper mapper; // mapper (in the same module)
@Override
public RegisterResult register(RegisterCommand cmd) {
var apiRequest = mapper.toApi(cmd);
var response = executeCall(() -> sberApi.register(apiRequest, null));
return mapper.toDomain(response);
}
@Override
public void cancel(PaymentId paymentId) {
executeCall(() -> sberApi.cancel(paymentId.value(), null));
}
private <T> T executeCall(Supplier<T> call) {
try {
return call.get();
} catch (FeignException e) {
throw new SberException("Sber call failed", e); // subclass of PaymentPortException
}
}
}
Spring automatically picks up SberClientAdapter as the implementation of PaymentPort — all it takes is @Component and implements PaymentPort. The handler in the core injects PaymentPort, and Spring supplies SberClientAdapter.
The adapter's job is tightly scoped: take a domain call → map it into the system's format → call the system → map the response back → return a domain result. That's all.
The mapper — a translator between worlds
External system details (Sber counts money in kopecks, uses numeric currency codes, returns numeric statuses) are an adapter detail. They must not leak into the core.
To translate between domain objects and Sber DTOs, you introduce a dedicated mapper class:
// sber-out-adapter/.../SberMapper.java
@Component
public class SberMapper {
public SberRegisterRequest toApi(RegisterCommand cmd) {
var req = new SberRegisterRequest();
req.setOrderNumber(cmd.orderId().value().toString());
req.setAmount(cmd.amount().amount().multiply(BigDecimal.valueOf(100)).intValue()); // rubles → kopecks
req.setCurrency(978); // 978 = RUB in Sber's format
req.setDescription(cmd.description());
return req;
}
public RegisterResult toDomain(SberRegisterResponse response) {
return new RegisterResult(
new PaymentId(response.getOrderId()),
URI.create(response.getFormUrl()),
mapStatus(response.getStatus())
);
}
private PaymentStatus mapStatus(Integer sberStatus) {
return switch (sberStatus) {
case 0 -> PaymentStatus.REGISTERED;
case 1 -> PaymentStatus.AUTHORIZED;
case 2 -> PaymentStatus.DEPOSITED;
case 3 -> PaymentStatus.CANCELLED;
default -> throw new SberException("Unknown Sber status: " + sberStatus, null);
};
}
}
The mapper knows everything about Sber-specific quirks. The core knows nothing. That's the whole point.
For simple conversions MapStruct works well. If there's non-trivial logic (unit conversion, enum mapping with a switch expression, calculations), a plain Java class is better: it's clearer and easier to debug.
What the adapter knows, and what it doesn't
Each adapter knows only its own technology and nothing about the others:
| Adapter | Knows | Doesn't know |
|---|---|---|
persistence/ | jOOQ, HikariCP, PostgreSQL | Sber API, Kafka |
sber-out-adapter/ | Sber SDK, RestClient, Resilience4j | PostgreSQL, Kafka |
kafka-out-adapter/ | KafkaTemplate, serializers | Sber, PostgreSQL |
This is spelled out explicitly in each module's build.gradle.kts — dependencies are declared explicitly, and Gradle won't let sber-out-adapter accidentally reach into jOOQ.
Common mistakes
Mistake 1: an external DTO in a port method
// Bad — a Sber DTO leaks into the core interface
public interface PaymentPort {
SberRegisterResponse register(RegisterCommand cmd); // ← Sber-specifics in the contract
}
The core must not know what SberRegisterResponse is. The port returns domain objects — RegisterResult, not a Sber DTO.
Mistake 2: business logic in the adapter
// Bad — the adapter decides business questions
@Override
public RegisterResult register(RegisterCommand cmd) {
if (cmd.amount().compareTo(Money.of(100_000)) > 0) { // ← business rule
throw new PaymentTooLargeException(cmd.amount());
}
var response = sberApi.register(mapper.toApi(cmd), null);
if (response.getStatus() == 4) {
sendNotification(cmd.orderId()); // ← side effect — not the adapter's job
}
return mapper.toDomain(response);
}
The 100,000 limit is a business rule; it lives in the core (handler or aggregate). If OdnaKassa appears tomorrow, the same limit would have to be copied. And sendNotification is a call to another port, which only the handler in the core makes. The adapter doesn't decide what to do after the system responds — it maps and returns.
Mistake 3: one adapter for several systems
// Bad — three unrelated systems in one class
public class UniversalIntegrationAdapter
implements PaymentPort, SmsPort, StoragePort { ... }
You can't configure the Circuit Breaker differently for three systems in one class. A Sber outage takes down both SMS and storage. Tests turn into tests of a "god class." Isolation disappears.
Mistake 4: an adapter injecting another adapter
// Bad — adapters depend on each other
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort {
private final OdnaKassaAdapter odnaKassaAdapter; // ← isolation violation
}
All adapters depend only on core/, never on each other. If a business scenario requires "try Sber, fall back to OdnaKassa on failure" — that's a use case in the core:
// core/.../FallbackPaymentHandler.java
@UseCaseHandler
@RequiredArgsConstructor
public class FallbackPaymentHandler {
private final SberPaymentPort sberPort;
private final OdnaKassaPaymentPort okPort;
public Payment handle(RegisterPaymentCommand cmd) {
try {
return sberPort.register(cmd);
} catch (PaymentPortException e) {
return okPort.register(cmd); // selection logic — in the core, not in the adapter
}
}
}
In short
- An outbound adapter is an implementation of a port interface from the core; it translates a domain call into a concrete technology (HTTP, SQL, Kafka) and back.
- One module — one external system. This gives you isolated dependencies, resilience configuration, metrics, and tests.
- The mapper in the adapter knows all the details of the external system (formats, codes, units); the core sees none of it.
- The adapter only maps and calls. Business rules live in the core; coordinating several systems also lives in the core.
- The port interface returns domain objects, never external-system DTOs.
- Adapters don't depend on each other. If you need orchestration, that's a use case in the core.
What to read next
- Ports in Hexagonal Architecture — what an outbound adapter implements and how to declare port interfaces.
- Inbound adapters — the symmetric side: how incoming requests reach the core.
- Repository pattern in jOOQ — a concrete outbound adapter (persistence) with jOOQ.