Back to Blog
spring-bootarchitecturehexagonaldddjavaclean-architecture

Hexagonal Architecture with Spring Boot: A Practical Guide (2026)

Hexagonal architecture (Ports and Adapters) keeps your business logic independent of frameworks. Learn how to structure a Spring Boot application with hexagonal architecture and why it matters.

J

JOptimize Team

May 23, 2026· 10 min read

Hexagonal architecture, also called Ports and Adapters, is a structural pattern that keeps your business logic completely independent of frameworks, databases, and external systems. The core idea: your domain code doesn't know Spring exists. This makes it testable, replaceable, and durable as technology evolves.


The Core Idea

Traditional layered architecture has dependencies flowing inward: Controller ? Service ? Repository. The problem: your service knows about JPA, Spring annotations, and HTTP. Switching the database or testing without a running Spring context is painful.

Hexagonal architecture inverts this: everything depends on the domain, never the other way around.

???????????????????????????????????????
?           ADAPTERS (outer)          ?
?  REST API ? CLI ? Kafka ? Scheduler  ?
?           ?     ?    ?      ?       ?
?         PORTS (interfaces)          ?
?           ?                         ?
?         DOMAIN (pure Java)          ?
?           ?                         ?
?         PORTS (interfaces)          ?
?  JPA Repo ? Redis ? S3 ? Email       ?
?           ADAPTERS (outer)          ?
???????????????????????????????????????

Project Structure

src/main/java/com/example/
??? domain/                          # Pure Java - no Spring, no JPA
?   ??? model/
?   ?   ??? Order.java               # Domain entity (not @Entity)
?   ?   ??? OrderStatus.java
?   ??? port/
?   ?   ??? in/                      # Incoming ports (use cases)
?   ?   ?   ??? CreateOrderUseCase.java
?   ?   ?   ??? GetOrderUseCase.java
?   ?   ??? out/                     # Outgoing ports (what domain needs)
?   ?       ??? OrderRepository.java
?   ?       ??? NotificationService.java
?   ??? service/
?       ??? OrderService.java        # Domain logic - implements use cases
?
??? adapter/
    ??? in/
    ?   ??? web/
    ?   ?   ??? OrderController.java  # Spring MVC adapter
    ?   ??? messaging/
    ?       ??? OrderEventListener.java
    ??? out/
        ??? persistence/
        ?   ??? OrderJpaRepository.java  # Spring Data
        ?   ??? OrderPersistenceAdapter.java
        ??? notification/
            ??? EmailNotificationAdapter.java

Step 1: The Domain Model (Pure Java)

// domain/model/Order.java - no Spring, no JPA, no framework public class Order { private final OrderId id; private final CustomerId customerId; private final List<OrderItem> items; private OrderStatus status; private Order(OrderId id, CustomerId customerId, List<OrderItem> items) { this.id = id; this.customerId = customerId; this.items = new ArrayList<>(items); this.status = OrderStatus.PENDING; } // Factory method - enforces invariants public static Order create(CustomerId customerId, List<OrderItem> items) { if (items.isEmpty()) throw new IllegalArgumentException("Order must have items"); return new Order(OrderId.generate(), customerId, items); } public void confirm() { if (status != OrderStatus.PENDING) throw new OrderStateException("Only PENDING orders can be confirmed"); this.status = OrderStatus.CONFIRMED; } public BigDecimal calculateTotal() { return items.stream() .map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); } }

Step 2: Ports (Interfaces)

// domain/port/in/CreateOrderUseCase.java - incoming port public interface CreateOrderUseCase { OrderId createOrder(CreateOrderCommand command); } // domain/port/in/GetOrderUseCase.java public interface GetOrderUseCase { Order getOrder(OrderId id); List<Order> getOrdersByCustomer(CustomerId customerId); } // domain/port/out/OrderRepository.java - outgoing port public interface OrderRepository { void save(Order order); Optional<Order> findById(OrderId id); List<Order> findByCustomerId(CustomerId customerId); } // domain/port/out/NotificationService.java public interface NotificationService { void sendOrderConfirmation(Order order); }

Step 3: Domain Service (Business Logic)

// domain/service/OrderService.java // Implements use cases, depends only on domain ports @Component // Only Spring annotation allowed in domain service public class OrderService implements CreateOrderUseCase, GetOrderUseCase { private final OrderRepository orderRepository; // Domain port private final NotificationService notifications; // Domain port public OrderService(OrderRepository orderRepository, NotificationService notifications) { this.orderRepository = orderRepository; this.notifications = notifications; } @Override public OrderId createOrder(CreateOrderCommand cmd) { Order order = Order.create(cmd.customerId(), cmd.items()); orderRepository.save(order); notifications.sendOrderConfirmation(order); return order.getId(); } @Override public Order getOrder(OrderId id) { return orderRepository.findById(id) .orElseThrow(() -> new OrderNotFoundException(id)); } }

Step 4: Adapters

// adapter/in/web/OrderController.java - REST adapter @RestController @RequestMapping("/api/orders") @RequiredArgsConstructor public class OrderController { private final CreateOrderUseCase createOrderUseCase; private final GetOrderUseCase getOrderUseCase; @PostMapping public ResponseEntity<OrderIdResponse> create( @RequestBody @Valid CreateOrderRequest request) { CreateOrderCommand cmd = OrderMapper.toCommand(request); OrderId id = createOrderUseCase.createOrder(cmd); return ResponseEntity.status(HttpStatus.CREATED) .body(new OrderIdResponse(id.value())); } @GetMapping("/{id}") public OrderResponse getOrder(@PathVariable String id) { Order order = getOrderUseCase.getOrder(new OrderId(id)); return OrderMapper.toResponse(order); } } // adapter/out/persistence/OrderPersistenceAdapter.java @Component @RequiredArgsConstructor public class OrderPersistenceAdapter implements OrderRepository { private final OrderJpaRepository jpaRepository; @Override public void save(Order order) { OrderJpaEntity entity = OrderMapper.toEntity(order); jpaRepository.save(entity); } @Override public Optional<Order> findById(OrderId id) { return jpaRepository.findById(id.value()) .map(OrderMapper::toDomain); } }

The Payoff: Testing Without Spring

// Test the domain service with no Spring context - instant, no DB needed class OrderServiceTest { private final OrderRepository orderRepository = mock(OrderRepository.class); private final NotificationService notifications = mock(NotificationService.class); private final OrderService orderService = new OrderService(orderRepository, notifications); @Test void shouldCreateOrderAndNotify() { CustomerId customerId = new CustomerId(1L); List<OrderItem> items = List.of(new OrderItem("SKU-1", 2, new BigDecimal("29.99"))); OrderId id = orderService.createOrder( new CreateOrderCommand(customerId, items) ); assertNotNull(id); verify(orderRepository).save(any(Order.class)); verify(notifications).sendOrderConfirmation(any(Order.class)); } } // Runs in <10ms, no Spring, no database

Common Mistakes to Avoid

  • Leaking JPA annotations into the domain model - if your Order class has @Entity, it's not hexagonal; keep two separate classes: domain Order and OrderJpaEntity
  • Putting Spring annotations in domain logic - @Autowired, @Transactional in the domain service couples it to Spring; inject via constructor instead
  • Over-engineering small apps - hexagonal architecture has overhead; use it for complex domains, not CRUD apps with 5 endpoints
  • Mapper explosion - every layer boundary needs a mapper; consider using MapStruct to reduce boilerplate

Summary

Hexagonal architecture separates your business logic from frameworks by using ports (interfaces) and adapters (implementations). The domain defines what it needs; adapters provide it. The result is a domain that's testable in milliseconds without Spring, swappable persistence layers, and business logic that survives framework upgrades.


Analyze Your Architecture with JOptimize

JOptimize's architecture analysis detects layer violations - domain code depending on persistence adapters, controllers calling repositories directly - and generates a dependency graph showing your current architecture.

Understand your architecture before it becomes legacy code - free scan, no configuration required.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.