| name | hexagonal-java |
| description | Hexagonal architecture (ports & adapters) for Java Spring Boot. Package structure, port definitions, use case implementation, adapter patterns, and testing strategy. Use when structuring or reviewing Java services. |
Hexagonal Architecture for Java / Spring Boot
Ports & Adapters architecture for testable, framework-independent Java services.
vs ddd-java: This skill focuses on package structure and dependency direction — ports (interfaces), adapters (infrastructure), use cases, and DI wiring with Spring. Use ddd-java when you need domain modeling — how to design Value Objects, Entities, Aggregates, and Domain Events.
When to Activate
- Structuring a new Java/Spring Boot service from scratch
- Reviewing whether domain logic has leaked into adapters (or vice versa)
- Deciding where a new class belongs in the package hierarchy
- Writing tests: determining what to mock and at which boundary
- Replacing an adapter (e.g., switching from JPA to JDBC) without touching domain
Core Principle
Dependency arrows always point inward — toward domain.
@startuml
!include <C4/C4_Container>
System_Boundary(svc, "Your Service") {
Container(in_adapt, "Inbound Adapters", "Spring MVC / Kafka / CLI", "drive the app")
Container(in_port, "Input Ports", "Java Interfaces", "domain/port/in/")
Container(usecase, "Use Cases", "Spring @Service", "application/usecase/")
Container(domain, "Domain Model", "Pure Java", "domain/model/ — no framework")
Container(out_port, "Output Ports", "Java Interfaces", "domain/port/out/")
Container(out_adapt, "Outbound Adapters", "JPA / REST Clients", "driven by the app")
}
Rel_D(in_adapt, in_port, "calls")
Rel_D(in_port, usecase, "implemented by")
Rel_D(usecase, domain, "uses")
Rel_D(domain, out_port, "defines")
Rel_D(out_port, out_adapt, "implemented by")
@enduml
Package Structure
src/main/java/com/example/app/
domain/
model/ # Market.java, Money.java, MarketStatus.java
port/
in/ # CreateMarketUseCase.java, ListMarketsUseCase.java
out/ # MarketRepository.java, NotificationPort.java
event/ # MarketCreatedEvent.java
application/
usecase/ # CreateMarketService.java, ListMarketsService.java
adapter/
in/
web/ # MarketController.java, CreateMarketRequest.java, MarketResponse.java
messaging/ # MarketEventConsumer.java
out/
persistence/ # JpaMarketRepository.java, MarketEntity.java, MarketMapper.java
client/ # NotificationClient.java
config/ # MarketConfig.java (@Configuration, bean wiring only)
Domain Model — No Framework Dependencies
public class Market {
private final MarketId id;
private final String name;
private final String slug;
private MarketStatus status;
private Market(MarketId id, String name, String slug, MarketStatus status) {
this.id = id;
this.name = name;
this.slug = slug;
this.status = status;
}
public static Market create(String name, String slug) {
if (name == null || name.isBlank()) throw new InvalidMarketException("name required");
return new Market(null, name, slug, MarketStatus.DRAFT);
}
public Market publish() {
if (this.status != MarketStatus.DRAFT) throw new MarketAlreadyPublishedException(slug);
return new Market(id, name, slug, MarketStatus.ACTIVE);
}
public String name { name; }
String { slug; }
MarketStatus { status; }
}
Input Ports (Use Case Interfaces)
public interface CreateMarketUseCase {
Market create(CreateMarketCommand command);
}
public record CreateMarketCommand(
@NonNull String name,
@NonNull String slug
) {}
Output Ports (Repository/External Service Interfaces)
public interface MarketRepository {
Market save(Market market);
Optional<Market> findBySlug(String slug);
List<Market> findAllActive(Pageable pageable);
}
public interface NotificationPort {
void notifyMarketCreated(Market market);
}
Use Case Implementation
@Transactional
public class CreateMarketService implements CreateMarketUseCase {
private final MarketRepository marketRepository;
private final NotificationPort notificationPort;
public CreateMarketService(
MarketRepository marketRepository,
NotificationPort notificationPort
) {
this.marketRepository = marketRepository;
this.notificationPort = notificationPort;
}
@Override
public Market create(CreateMarketCommand command) {
var market = Market.create(command.name(), command.slug());
var saved = marketRepository.save(market);
notificationPort.notifyMarketCreated(saved);
return saved;
}
}
Inbound Adapter — REST Controller
@RestController
@RequestMapping("/api/markets")
@Validated
class MarketController {
private final CreateMarketUseCase createMarket;
MarketController(CreateMarketUseCase createMarket) {
this.createMarket = createMarket;
}
@PostMapping
ResponseEntity<MarketResponse> create(@Valid @RequestBody CreateMarketRequest req) {
var command = new CreateMarketCommand(req.name(), req.slug());
var market = createMarket.create(command);
return ResponseEntity.status(HttpStatus.CREATED).body(MarketResponse.from(market));
}
}
public record CreateMarketRequest(
@NotBlank @Size(max = 200) String name,
@NotBlank @Pattern(regexp = "[a-z0-9-]+") String slug
) {}
public record MarketResponse(String name, String slug, String status) {
public static MarketResponse from(Market market) {
return new MarketResponse(market.name(), market.slug(), market.status().name());
}
}
Outbound Adapter — Persistence
@Repository
class JpaMarketRepository implements MarketRepository {
private final MarketJpaRepository jpaRepo;
JpaMarketRepository(MarketJpaRepository jpaRepo) {
this.jpaRepo = jpaRepo;
}
@Override
public Market save(Market market) {
MarketEntity entity = MarketMapper.toEntity(market);
return MarketMapper.toDomain(jpaRepo.save(entity));
}
@Override
public Optional<Market> findBySlug(String slug) {
return jpaRepo.findBySlug(slug).map(MarketMapper::toDomain);
}
@Override
public List<Market> findAllActive(Pageable pageable) {
return jpaRepo.findByStatus(MarketStatus.ACTIVE, pageable)
.stream().map(MarketMapper::toDomain).toList();
}
}
@Entity
@Table(name = "markets")
class MarketEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String name;
String slug;
@Enumerated(EnumType.STRING)
MarketStatus status;
}
interface MarketJpaRepository <MarketEntity, Long> {
Optional<MarketEntity> ;
List<MarketEntity> ;
}
Spring Bean Wiring
@Configuration
class MarketConfig {
@Bean
CreateMarketUseCase createMarketUseCase(
MarketRepository marketRepository,
NotificationPort notificationPort
) {
return new CreateMarketService(marketRepository, notificationPort);
}
}
Testing Strategy
Unit Test — Use Case (fast, no Spring)
Mock output ports; test business logic in isolation:
@ExtendWith(MockitoExtension.class)
class CreateMarketUseCaseTest {
@Mock MarketRepository marketRepository;
@Mock NotificationPort notificationPort;
@InjectMocks CreateMarketService createMarket;
@Test
void create_savesMarketAndNotifies() {
var command = new CreateMarketCommand("Test Market", "test-market");
given(marketRepository.save(any())).willAnswer(inv -> inv.getArgument(0));
var result = createMarket.create(command);
assertThat(result.name()).isEqualTo("Test Market");
verify(marketRepository).save(any());
verify(notificationPort).notifyMarketCreated(any());
}
@Test
void create_throwsException_whenNameIsBlank() {
assertThatThrownBy(() -> createMarket.create(new CreateMarketCommand("", "slug")))
.isInstanceOf(InvalidMarketException.class);
}
}
Adapter Test — Web Layer (@WebMvcTest)
Mock input port; test HTTP concerns only:
@WebMvcTest(MarketController.class)
class MarketControllerTest {
@Autowired MockMvc mockMvc;
@MockBean CreateMarketUseCase createMarket;
@Test
void create_returns201_withValidPayload() throws Exception {
given(createMarket.create(any())).willReturn(
Market.create("Test", "test-market")
);
mockMvc.perform(post("/api/markets")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"name":"Test","slug":"test-market"}"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Test"));
}
}
Adapter Test — Persistence Layer (@DataJpaTest)
Test the JPA adapter against a real database schema:
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
@Import(TestContainersConfig.class)
class JpaMarketRepositoryTest {
@Autowired MarketJpaRepository jpaRepo;
JpaMarketRepository repository;
@BeforeEach
void setUp() { repository = new JpaMarketRepository(jpaRepo); }
@Test
void save_andFindBySlug() {
var market = Market.create("Test", "test-slug");
repository.save(market);
assertThat(repository.findBySlug("test-slug")).isPresent();
}
}
Anti-Patterns
Domain Class Importing Spring or JPA Annotations
Wrong:
import org.springframework.stereotype.Component;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "markets")
@Component
public class Market { ... }
Correct:
public class Market {
private final MarketId id;
private final String name;
private MarketStatus status;
private Market(MarketId id, String name, MarketStatus status) { ... }
}
Why: JPA and Spring annotations in the domain couple business logic to persistence and the container, breaking testability without a running Spring context.
Controller Injecting the Use Case Implementation Instead of the Port
Wrong:
@RestController
class MarketController {
private final CreateMarketService createMarketService;
MarketController(CreateMarketService createMarketService) {
this.createMarketService = createMarketService;
}
}
Correct:
@RestController
class MarketController {
private final CreateMarketUseCase createMarket;
MarketController(CreateMarketUseCase createMarket) {
this.createMarket = createMarket;
}
}
Why: Depending on the concrete service class instead of the port interface prevents mocking in @WebMvcTest and leaks application-layer details into the adapter.
@Transactional Placed on Domain Model Methods
Wrong:
public class Market {
@Transactional
public Market publish() {
if (this.status != MarketStatus.DRAFT) throw new MarketAlreadyPublishedException(slug);
return new Market(id, name, slug, MarketStatus.ACTIVE);
}
}
Correct:
@Transactional
public class PublishMarketService implements PublishMarketUseCase {
public Market publish(MarketId id) {
var market = marketRepository.findById(id).orElseThrow();
var published = market.publish();
return marketRepository.save(published);
}
}
Why: @Transactional is an infrastructure concern; placing it on the domain model introduces a Spring dependency and the wrong transactional scope.
Use Case Importing JPA Entity or Repository Interface
Wrong:
import com.example.adapter.out.persistence.MarketEntity;
import com.example.adapter.out.persistence.MarketJpaRepository;
public class CreateMarketService implements CreateMarketUseCase {
private final MarketJpaRepository jpaRepository;
}
Correct:
import com.example.domain.port.out.MarketRepository;
public class CreateMarketService implements CreateMarketUseCase {
private final MarketRepository marketRepository;
}
Why: Importing JPA artifacts into the use case couples the application layer to the persistence technology, breaking the ability to swap databases without touching business logic.
Mapping Skipped — Returning JPA Entity from Use Case
Wrong:
public List<MarketEntity> listActive() {
return jpaRepository.findByStatus(MarketStatus.ACTIVE, Pageable.unpaged());
}
Correct:
public List<Market> listActive(Pageable pageable) {
return marketRepository.findAllActive(pageable);
}
public List<Market> findAllActive(Pageable pageable) {
return jpaRepo.findByStatus(MarketStatus.ACTIVE, pageable)
.stream().map(MarketMapper::toDomain).toList();
}
Why: Allowing JPA entities to escape the persistence adapter exposes database schema details to callers and makes schema changes break the entire codebase.
Architecture Violation Checklist
When Hexagonal is Worth the Overhead
- Domain logic is complex (multiple invariants, domain events, aggregate roots)
- Multiple inbound adapters are likely (REST + CLI + message consumer)
- Persistence technology may change (JPA → JDBC → MongoDB)
- High test coverage of domain logic is required without starting Spring
- Team practices TDD and wants pure-Java unit tests
Relationship to DDD
Hexagonal architecture is the structural container; DDD provides the modeling substance:
| Hexagonal | DDD |
|---|
domain/model/ | Entities, Value Objects, Aggregates |
domain/port/out/ | Repository interface (per Aggregate Root) |
domain/event/ | Domain Events (raised inside Aggregates) |
domain/service/ | Domain Services (stateless, no framework) |
application/usecase/ | Application Services (orchestrate, dispatch events) |
adapter/out/persistence/ | JPA entities + mappers (NOT domain entities) |
Neither is complete without the other: Hexagonal without DDD produces anemic models with behavior in use cases. DDD without hexagonal produces framework-coupled domain objects.
For DDD modeling patterns (Value Objects, Aggregates, Domain Services, Domain Events), see skill: ddd-java.