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.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
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. Useddd-javawhen you need domain modeling — how to design Value Objects, Entities, Aggregates, and Domain Events.
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
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/Market.java
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;
}
// Factory method — domain logic, not Spring
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);
}
// Behavior methods
public Market publish() {
if (this.status != MarketStatus.DRAFT) throw new MarketAlreadyPublishedException(slug);
return new Market(id, name, slug, MarketStatus.ACTIVE);
}
public String name() { return name; }
public String slug() { return slug; }
public MarketStatus status() { return status; }
}
// domain/port/in/CreateMarketUseCase.java
public interface CreateMarketUseCase {
Market create(CreateMarketCommand command);
}
// domain/port/in/CreateMarketCommand.java (command = validated input)
public record CreateMarketCommand(
@NonNull String name,
@NonNull String slug
) {}
// domain/port/out/MarketRepository.java
public interface MarketRepository {
Market save(Market market);
Optional<Market> findBySlug(String slug);
List<Market> findAllActive(Pageable pageable);
}
// domain/port/out/NotificationPort.java
public interface NotificationPort {
void notifyMarketCreated(Market market);
}
// application/usecase/CreateMarketService.java
// @Transactional lives here — not in domain, not in adapter
@Transactional
public class CreateMarketService implements CreateMarketUseCase {
private final MarketRepository marketRepository; // output port
private final NotificationPort notificationPort; // output port
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;
}
}
// adapter/in/web/MarketController.java
@RestController
@RequestMapping("/api/markets")
@Validated
class MarketController {
private final CreateMarketUseCase createMarket; // input port only
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));
}
}
// adapter/in/web/CreateMarketRequest.java
public record CreateMarketRequest(
@NotBlank @Size(max = 200) String name,
@NotBlank @Pattern(regexp = "[a-z0-9-]+") String slug
) {}
// adapter/in/web/MarketResponse.java
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());
}
}
// adapter/out/persistence/JpaMarketRepository.java
@Repository
class JpaMarketRepository implements MarketRepository { // implements output port
private final MarketJpaRepository jpaRepo; // Spring Data interface
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();
}
}
// adapter/out/persistence/MarketEntity.java — JPA annotations stay here, not in domain
@Entity
@Table(name = "markets")
class MarketEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String name;
String slug;
@Enumerated(EnumType.STRING)
MarketStatus status;
}
// adapter/out/persistence/MarketJpaRepository.java
interface MarketJpaRepository extends JpaRepository<MarketEntity, Long> {
Optional<MarketEntity> findBySlug(String slug);
List<MarketEntity> findByStatus(MarketStatus status, Pageable pageable);
}
// config/MarketConfig.java
@Configuration
class MarketConfig {
@Bean
CreateMarketUseCase createMarketUseCase(
MarketRepository marketRepository,
NotificationPort notificationPort
) {
return new CreateMarketService(marketRepository, notificationPort);
}
}
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);
}
}
@WebMvcTest)Mock input port; test HTTP concerns only:
@WebMvcTest(MarketController.class)
class MarketControllerTest {
@Autowired MockMvc mockMvc;
@MockBean CreateMarketUseCase createMarket; // input port — not the use case impl
@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"));
}
}
@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();
}
}
Wrong:
// domain/model/Market.java
import org.springframework.stereotype.Component;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "markets")
@Component
public class Market { ... } // framework annotations pollute domain
Correct:
// domain/model/Market.java
// No framework imports — plain Java only
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.
Wrong:
// adapter/in/web/MarketController.java
@RestController
class MarketController {
private final CreateMarketService createMarketService; // concrete class
MarketController(CreateMarketService createMarketService) {
this.createMarketService = createMarketService;
}
}
Correct:
// adapter/in/web/MarketController.java
@RestController
class MarketController {
private final CreateMarketUseCase createMarket; // input port interface
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.
Wrong:
// domain/model/Market.java
public class Market {
@Transactional // Spring annotation has no business being here
public Market publish() {
if (this.status != MarketStatus.DRAFT) throw new MarketAlreadyPublishedException(slug);
return new Market(id, name, slug, MarketStatus.ACTIVE);
}
}
Correct:
// application/usecase/PublishMarketService.java
@Transactional // transaction boundary belongs on the use case
public class PublishMarketService implements PublishMarketUseCase {
public Market publish(MarketId id) {
var market = marketRepository.findById(id).orElseThrow();
var published = market.publish(); // pure domain call
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.
Wrong:
// application/usecase/CreateMarketService.java
import com.example.adapter.out.persistence.MarketEntity;
import com.example.adapter.out.persistence.MarketJpaRepository;
public class CreateMarketService implements CreateMarketUseCase {
private final MarketJpaRepository jpaRepository; // Spring Data leaks into application layer
}
Correct:
// application/usecase/CreateMarketService.java
import com.example.domain.port.out.MarketRepository; // output port only
public class CreateMarketService implements CreateMarketUseCase {
private final MarketRepository marketRepository; // domain-defined interface
}
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.
Wrong:
// application/usecase/ListMarketsService.java
public List<MarketEntity> listActive() { // JPA entity escapes the adapter
return jpaRepository.findByStatus(MarketStatus.ACTIVE, Pageable.unpaged());
}
Correct:
// application/usecase/ListMarketsService.java
public List<Market> listActive(Pageable pageable) {
return marketRepository.findAllActive(pageable); // returns domain objects via output port
}
// adapter/out/persistence/JpaMarketRepository.java — mapper lives here
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.
org.springframework.* → violationjavax.persistence.* / jakarta.persistence.* → violationMarketEntity or MarketJpaRepository → violationadapter/in/ imports adapter/out/ → violation@Transactional on domain model methods → violation (belongs in use case)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.