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.
JOptimize Team
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.
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) ? ???????????????????????????????????????
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
// 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); } }
// 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); }
// 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)); } }
// 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); } }
// 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
Order class has @Entity, it's not hexagonal; keep two separate classes: domain Order and OrderJpaEntity@Autowired, @Transactional in the domain service couples it to Spring; inject via constructor insteadHexagonal 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.
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.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.