Hexagonal Architecture
Use when the project follows hexagonal (ports & adapters) architecture. Prevents domain code from depending on Spring or JPA. Use when you see packages like domain/, application/, infrastructure/, or adapters/ in the project structure.
View SKILL.mdPackage Structure
src/main/java/com/example/
├── domain/ ← Pure Java. Zero framework dependencies.
│ ├── model/ ← Entities, value objects, aggregates
│ ├── port/
│ │ ├── in/ ← Use case interfaces (driving ports)
│ │ └── out/ ← Repository/external interfaces (driven ports)
│ └── service/ ← Domain services (pure business logic)
├── application/ ← Orchestrates use cases. Spring allowed here.
│ └── usecase/ ← @Service implementations of domain ports
└── infrastructure/ ← All framework/DB/HTTP details
├── persistence/ ← JPA adapters implementing out ports
├── web/ ← REST controllers (driving adapters)
└── external/ ← HTTP clients, messaging adapters
Domain Layer — Zero Spring
// domain/model/Order.java — pure Java, no annotations
public class Order {
private final OrderId id;
private final CustomerId customerId;
private OrderStatus status;
private final List<OrderItem> items;
private Order(OrderId id, CustomerId customerId) {
this.id = id;
this.customerId = customerId;
this.status = OrderStatus.PENDING;
this.items = new ArrayList<>();
}
public static Order create(CustomerId customerId) {
return new Order(OrderId.generate(), customerId);
}
public void addItem(ProductId productId, int quantity, Money price) {
if (status != OrderStatus.PENDING)
throw new OrderNotModifiableException(id);
items.add(new OrderItem(productId, quantity, price));
}
// Getters only — no setters
}
// domain/model/OrderId.java — value object
public record OrderId(UUID value) {
public static OrderId generate() { return new OrderId(UUID.randomUUID()); }
public static OrderId of(String value) { return new OrderId(UUID.fromString(value)); }
}
Ports — Interfaces Only
// domain/port/in/CreateOrderUseCase.java — driving port
public interface CreateOrderUseCase {
Order createOrder(CreateOrderCommand command);
}
// domain/port/in/CreateOrderCommand.java
public record CreateOrderCommand(CustomerId customerId, List<OrderItemData> items) {}
// domain/port/out/OrderRepository.java — driven port
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
void deleteById(OrderId id);
}
// domain/port/out/InventoryPort.java — driven port
public interface InventoryPort {
void reserve(List<OrderItem> items);
void release(List<OrderItem> items);
}
Application Layer — Use Case Implementation
// application/usecase/CreateOrderService.java
@Service // Spring allowed here
@RequiredArgsConstructor
@Transactional
public class CreateOrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository; // domain port (not JPA repo)
private final InventoryPort inventoryPort; // domain port
@Override
public Order createOrder(CreateOrderCommand command) {
Order order = Order.create(command.customerId());
command.items().forEach(item ->
order.addItem(item.productId(), item.quantity(), item.price()));
inventoryPort.reserve(order.getItems());
return orderRepository.save(order);
}
}
Infrastructure — Adapters
// infrastructure/persistence/JpaOrderRepository.java — implements domain port
@Repository
@RequiredArgsConstructor
public class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderRepository springDataRepo;
private final OrderMapper mapper;
@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toEntity(order);
return mapper.toDomain(springDataRepo.save(entity));
}
@Override
public Optional<Order> findById(OrderId id) {
return springDataRepo.findById(id.value()).map(mapper::toDomain);
}
@Override
public void deleteById(OrderId id) {
springDataRepo.deleteById(id.value());
}
}
// Separate Spring Data interface — infrastructure detail
interface SpringDataOrderRepository extends JpaRepository<OrderJpaEntity, UUID> {}
// infrastructure/web/OrderController.java — driving adapter
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final CreateOrderUseCase createOrderUseCase; // injects use case port
@PostMapping
public ResponseEntity<ApiResponse<OrderResponse>> create(@Valid @RequestBody CreateOrderRequest request) {
Order order = createOrderUseCase.createOrder(request.toCommand());
return ResponseEntity.status(201).body(ApiResponse.ok(OrderResponse.from(order)));
}
}
Gotchas
- Agent imports
javax.persistencein domain classes — domain must be framework-free - Agent injects
JpaRepositorydirectly into use cases — use domain port interfaces - Agent puts
@Transactionalon domain services — belongs in application layer - Agent mixes driving and driven ports —
port/in= what app offers,port/out= what app needs - Agent creates anemic domain with only getters/setters — behavior belongs on domain objects
Examples & templates
bad-use-case.javaexamples
Source on GitHub
// ❌ BAD — infrastructure leaks into domain, anemic model, no ports
@Service
@RequiredArgsConstructor
public class CreateOrderService {
private final OrderJpaRepository orderRepository; // JPA repo directly — no domain port
private final RestTemplate restTemplate; // infrastructure in application layer
@Transactional
public OrderEntity createOrder(CreateOrderRequest request) {
// Business logic in service, not domain model (anemic)
if (request.getItems().isEmpty()) {
throw new RuntimeException("No items"); // generic exception, no domain meaning
}
OrderEntity order = new OrderEntity(); // JPA entity used as domain model
order.setCustomerEmail(request.getEmail()); // setter-based, no invariant enforcement
order.setStatus("PENDING"); // string instead of enum/value object
for (ItemRequest item : request.getItems()) {
OrderItemEntity orderItem = new OrderItemEntity(); // direct child construction
orderItem.setProductId(item.getProductId());
orderItem.setQuantity(item.getQuantity());
order.getItems().add(orderItem); // bypasses aggregate root
}
// Infrastructure call mixed with business logic
restTemplate.postForEntity( // HTTP call in service, not behind port
"http://inventory-service/reserve",
request.getItems(), Void.class);
return orderRepository.save(order); // returns JPA entity, not domain model
}
}
// Domain model polluted with JPA
@Entity // JPA annotation in domain model
@Data // generates setters — no encapsulation
public class OrderEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // Long ID, not typed value object
private String customerEmail;
private String status; // String instead of OrderStatus enum
@OneToMany private List<OrderItemEntity> items; // missing cascade, orphanRemoval
}good-use-case.javaexamples
Source on GitHub
// ✅ GOOD — use case implements port, injects domain ports, no Spring annotations in domain
// --- Domain port (in domain layer, no Spring imports) ---
public interface CreateOrderUseCase {
Order execute(CreateOrderCommand command);
}
public interface OrderRepository { // domain port — not Spring's JpaRepository
Order save(Order order);
Optional<Order> findById(OrderId id);
}
// --- Application layer (implements use case, orchestrates domain) ---
@Service
@RequiredArgsConstructor
@Transactional
public class CreateOrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository; // domain port, not JPA
private final InventoryPort inventoryPort; // domain port for external system
@Override
public Order execute(CreateOrderCommand command) {
inventoryPort.reserve(command.items());
Order order = Order.create(command.customerEmail(), command.items());
return orderRepository.save(order);
}
}
// --- Domain model (no JPA annotations, pure business logic) ---
public class Order {
private final OrderId id;
private final EmailAddress customerEmail;
private OrderStatus status;
private final List<OrderItem> items;
public static Order create(EmailAddress email, List<OrderItemCommand> items) {
Order order = new Order(OrderId.generate(), email, OrderStatus.PENDING, new ArrayList<>());
items.forEach(i -> order.addItem(i.productId(), i.quantity()));
return order;
}
public void addItem(ProductId productId, int quantity) {
if (status != OrderStatus.PENDING)
throw new OrderNotModifiableException(id);
items.add(new OrderItem(productId, quantity));
}
}OrderJpaAdapter.javatemplates
Source on GitHub
package com.example.order.infrastructure.persistence;
import com.example.order.domain.model.Order;
import com.example.order.domain.model.OrderId;
import com.example.order.domain.port.out.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* Persistence adapter — implements domain port using JPA.
* Domain layer never sees JPA, only this adapter does.
*/
@Component
@RequiredArgsConstructor
public class OrderJpaAdapter implements OrderRepository {
private final SpringDataOrderRepository jpaRepository;
private final OrderPersistenceMapper mapper;
@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toJpaEntity(order);
OrderJpaEntity saved = jpaRepository.save(entity);
return mapper.toDomainModel(saved);
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value())
.map(mapper::toDomainModel);
}
@Override
public void deleteById(OrderId id) {
jpaRepository.deleteById(id.value());
}
}
/**
* Spring Data JPA repository — internal to infrastructure layer.
* Never exposed outside this package.
*/
interface SpringDataOrderRepository extends org.springframework.data.jpa.repository.JpaRepository<OrderJpaEntity, java.util.UUID> {
}OrderJpaEntity.javatemplates
Source on GitHub
package com.example.order.infrastructure.persistence;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* JPA entity — lives in infrastructure layer, separate from domain model.
* Maps between database schema and persistence. Never used in domain logic.
*/
@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderJpaEntity {
@Id
@Column(updatable = false, nullable = false)
private UUID id;
@Column(name = "customer_email", nullable = false)
private String customerEmail;
// Stored as the enum's name; the persistence mapper converts to/from OrderStatus.
// (@Enumerated only applies to enum-typed fields — on a String Hibernate fails at startup.)
@Column(nullable = false)
private String status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItemJpaEntity> items = new ArrayList<>();
@CreationTimestamp
@Column(updatable = false)
private Instant createdAt;
@UpdateTimestamp
private Instant updatedAt;
@Version
private Long version;
// Used by mapper only — no public constructor
OrderJpaEntity(UUID id, String customerEmail, String status) {
this.id = id;
this.customerEmail = customerEmail;
this.status = status;
}
void addItem(OrderItemJpaEntity item) {
items.add(item);
item.setOrder(this);
}
}Verification scope
- Compiled sources
- No
- Runtime checks
- No
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 3 skills
Install Boot 3 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-3-skills@spring-boot-skills