How to structure a Spring Boot service using hexagonal architecture — defining ports as interfaces, writing adapters for HTTP and persistence, and keeping the domain completely framework-free.
Hexagonal architecture — also called Ports and Adapters — separates your domain logic from the infrastructure it runs on. The domain knows nothing about HTTP, databases, or message brokers. Those concerns live in adapters that translate between the domain and the outside world through interfaces called ports. The result is a codebase where the core logic is independently testable, the infrastructure is swappable, and the coupling runs only inward — adapters depend on the domain, never the reverse.
┌───────────────────────────────────────────┐
│ Driving Adapters │
│ (REST controllers, CLI, message listeners)│
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Application / Ports │ │
│ │ (use case interfaces) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Domain (pure Java) │ │
│ │ (entities, value objects, │ │
│ │ domain services) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Driven Ports │ │
│ │ (repository interfaces) │ │
│ └─────────────────────────────┘ │
│ │ │
│ ▼ │
│ Driven Adapters │
│ (JPA, S3, Kafka, Betfair client) │
└───────────────────────────────────────────┘
Driving adapters call into the application. Driven adapters are called by the application. The domain sits in the centre and knows about neither.
com.trinitylogic.trading
├── domain
│ ├── model Order, Runner, Market (pure Java)
│ └── service OrderPricingService (no framework)
├── application
│ ├── port
│ │ ├── in PlaceOrderUseCase, GetMarketUseCase (interfaces)
│ │ └── out OrderRepository, MarketDataPort (interfaces)
│ └── service OrderApplicationService (implements in, uses out)
├── adapter
│ ├── in
│ │ └── rest OrderController, MarketController (Spring MVC)
│ └── out
│ ├── persistence OrderJpaAdapter (implements OrderRepository)
│ ├── betfair BetfairMarketAdapter (implements MarketDataPort)
│ └── messaging KafkaOrderEventAdapter
└── infrastructure
└── config SpringConfig, JpaConfig, SecurityConfig
The rule: nothing in domain imports from application, adapter, or Spring. Nothing in application imports from adapter.
A port is a plain Java interface expressing what the application can do:
// application/port/in/PlaceOrderUseCase.java
public interface PlaceOrderUseCase {
OrderId placeOrder(PlaceOrderCommand command);
}
public record PlaceOrderCommand(
MarketId marketId,
SelectionId selectionId,
Price requestedPrice,
Stake stake,
Side side
) {}
Strong domain types — MarketId, Price, Stake — rather than primitives. The interface says nothing about HTTP.
// application/service/OrderApplicationService.java
@Service
@RequiredArgsConstructor
class OrderApplicationService implements PlaceOrderUseCase {
private final OrderRepository orderRepository; // driven port
private final MarketDataPort marketDataPort; // driven port
@Override
public OrderId placeOrder(PlaceOrderCommand command) {
Market market = marketDataPort.getMarket(command.marketId())
.orElseThrow(() -> new MarketNotFoundException(command.marketId()));
if (!market.isOpen()) {
throw new MarketClosedException(command.marketId());
}
Order order = Order.create(command);
orderRepository.save(order);
return order.id();
}
}
No Spring MVC, no JPA, no Betfair API. Pure application logic.
// application/port/out/OrderRepository.java
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findByMarketId(MarketId marketId);
}
// adapter/out/persistence/OrderJpaAdapter.java
@Component
@RequiredArgsConstructor
class OrderJpaAdapter implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderMapper mapper;
@Override
public void save(Order order) {
jpaRepository.save(mapper.toEntity(order));
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value())
.map(mapper::toDomain);
}
@Override
public List<Order> findByMarketId(MarketId marketId) {
return jpaRepository.findByMarketId(marketId.value())
.stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
}
}
The mapper converts between domain types and JPA entities. The domain model never extends @Entity or references Jakarta Persistence.
// adapter/in/rest/OrderController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/orders")
class OrderController {
private final PlaceOrderUseCase placeOrderUseCase;
@PostMapping
ResponseEntity<PlaceOrderResponse> placeOrder(@Valid @RequestBody PlaceOrderRequest req) {
PlaceOrderCommand command = PlaceOrderRequest.toCommand(req);
OrderId orderId = placeOrderUseCase.placeOrder(command);
return ResponseEntity
.created(URI.create("/orders/" + orderId.value()))
.body(new PlaceOrderResponse(orderId.value()));
}
}
The controller knows about HTTP and JSON. It does not know about JPA, Betfair, or business rules.
The architecture pays off at test time. The application service can be tested with mock adapters:
@ExtendWith(MockitoExtension.class)
class OrderApplicationServiceTest {
@Mock OrderRepository orderRepository;
@Mock MarketDataPort marketDataPort;
OrderApplicationService underTest;
@BeforeEach
void setUp() {
underTest = new OrderApplicationService(orderRepository, marketDataPort);
}
@Test
void placeOrder_savesOrderWhenMarketIsOpen() {
given(marketDataPort.getMarket(any())).willReturn(Optional.of(openMarket()));
underTest.placeOrder(validCommand());
verify(orderRepository).save(any(Order.class));
}
}
No HTTP, no database, no Betfair connection. Millisecond test execution.
The REST adapter is tested with @WebMvcTest — HTTP mechanics, request parsing, response shape. The JPA adapter is tested with @DataJpaTest and a real (embedded or containerised) database. Each component is tested at the right level with the right tool.
Spring is in the adapters and infrastructure. @Service, @Repository, @RestController, @Component — all in the adapter and infrastructure layers. The domain and application layers are plain Java. If you ever need to run the core logic outside Spring — in a batch job, a Lambda, a test — nothing needs to change.
If you’re designing a new Spring Boot service and want help with the architectural structure, get in touch.