Hire Me
← All Writing Architecture

Hexagonal Architecture — Structuring a Spring Boot Service with Ports and Adapters

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.

The three zones

┌───────────────────────────────────────────┐
│  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.

Package structure

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.

Incoming port

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 implementing the port

// 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.

Outgoing port

// application/port/out/OrderRepository.java
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
    List<Order> findByMarketId(MarketId marketId);
}

JPA adapter implementing the port

// 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.

REST adapter calling the port

// 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.

Testing in isolation

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.

Where Spring lives

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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.