
Clean Architecture
- 25 installs
- 318 repo stars
- Updated June 22, 2026
- giuseppe-trisciuoglio/developer-kit-claude-code
This is a copy of clean-architecture by giuseppe-trisciuoglio - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
clean-architecture is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- clean-architecture
- AI & Agent Building
- AI-coding skill
Clean Architecture by the numbers
- 25 all-time installs (skills.sh)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/giuseppe-trisciuoglio/developer-kit-claude-code --skill clean-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25 |
|---|---|
| repo stars | ★ 318 |
| Last updated | June 22, 2026 |
| Repository | giuseppe-trisciuoglio/developer-kit-claude-code ↗ |
What it does
Helps with ai & agent building tasks.
Files
Clean Architecture, Hexagonal Architecture & DDD for Spring Boot
Overview
This skill provides comprehensive guidance for implementing Clean Architecture, Hexagonal Architecture (Ports & Adapters), and Domain-Driven Design tactical patterns in Java 21+ Spring Boot 3.5+ applications. It ensures clear separation of concerns, framework-independent domain logic, and highly testable codebases through proper layering and dependency management.
When to Use
- Architecting new Spring Boot applications with clear separation of concerns
- Refactoring tightly coupled code into testable, layered architectures
- Implementing domain logic independent of frameworks and infrastructure
- Designing ports and adapters for swappable implementations
- Applying Domain-Driven Design tactical patterns (entities, value objects, aggregates)
- Creating testable business logic without Spring context dependencies
Instructions
1. Understand the Core Concepts
Clean Architecture Layers (Dependency Rule)
Dependencies flow inward. Inner layers know nothing about outer layers.
| Layer | Responsibility | Spring Boot Equivalent |
|---|---|---|
| Domain | Entities, value objects, domain events, repository interfaces | domain/ - no Spring annotations |
| Application | Use cases, application services, DTOs, ports | application/ - @Service, @Transactional |
| Infrastructure | Frameworks, database, external APIs | infrastructure/ - @Repository, @Entity |
| Adapter | Controllers, presenters, external gateways | adapter/ - @RestController |
Hexagonal Architecture (Ports & Adapters)
- Domain Core: Pure Java business logic, no framework dependencies
- Ports: Interfaces defining contracts (driven and driving)
- Adapters: Concrete implementations (JPA, REST, messaging)
Domain-Driven Design Tactical Patterns
- Entities: Objects with identity and lifecycle (e.g.,
Order,Customer) - Value Objects: Immutable, defined by attributes (e.g.,
Money,Email) - Aggregates: Consistency boundary with root entity
- Domain Events: Capture significant business occurrences
- Repositories: Persistence abstraction, implemented in infrastructure
2. Organize Package Structure
Follow this feature-based package organization:
com.example.order/
├── domain/
│ ├── model/ # Entities, value objects
│ ├── event/ # Domain events
│ ├── repository/ # Repository interfaces (ports)
│ └── exception/ # Domain exceptions
├── application/
│ ├── port/in/ # Driving ports (use case interfaces)
│ ├── port/out/ # Driven ports (external service interfaces)
│ ├── service/ # Application services
│ └── dto/ # Request/response DTOs
├── infrastructure/
│ ├── persistence/ # JPA entities, repository adapters
│ └── external/ # External service adapters
└── adapter/
└── rest/ # REST controllers3. Implement the Domain Layer (Framework-Free)
The domain layer must have zero dependencies on Spring or any framework.
- Use Java records for immutable value objects with built-in validation
- Place business logic in entities, not services (Rich Domain Model)
- Define repository interfaces (ports) in the domain layer
- Use strongly-typed IDs to prevent ID confusion
- Implement domain events for decoupling side effects
- Use factory methods for entity creation to enforce invariants
4. Implement the Application Layer
- Create use case interfaces (driving ports) in
application/port/in/ - Create external service interfaces (driven ports) in
application/port/out/ - Implement application services with
@Serviceand@Transactional - Use DTOs for request/response, separate from domain models
- Publish domain events after successful operations
5. Implement the Infrastructure Layer (Adapters)
- Create JPA entities in
infrastructure/persistence/ - Implement repository adapters that map between domain and JPA entities
- Use MapStruct or manual mappers for domain-JPA conversion
- Configure conditional beans for swappable implementations
- Keep infrastructure concerns isolated from domain logic
6. Implement the Adapter Layer (REST)
- Create REST controllers in
adapter/rest/ - Inject use case interfaces, not implementations
- Use Bean Validation on DTOs
- Return proper HTTP status codes and responses
- Handle exceptions with global exception handlers
7. Apply Best Practices
1. Dependency Rule: Domain has zero dependencies on Spring or other frameworks 2. Immutable Value Objects: Use Java records for value objects with built-in validation 3. Rich Domain Models: Place business logic in entities, not services 4. Repository Pattern: Domain defines interface, infrastructure implements 5. Domain Events: Decouple side effects from primary operations 6. Constructor Injection: Mandatory dependencies via final fields 7. DTO Mapping: Separate domain models from API contracts 8. Transaction Boundaries: Place @Transactional in application services 9. Factory Methods: Use Entity.create() for invariant enforcement during construction 10. Separate JPA Entities: Keep domain entities separate from JPA entities with mappers
8. Validate Architecture Compliance
After implementing each layer, verify the dependency rules are respected:
- Domain Layer Check: Run
grep -r "@Service\|@Component\|@Autowired" domain/to ensure zero Spring imports - ArchUnit Test: Add dependency tests to verify no infrastructure imports in domain layer:
noClasses().that().resideInPackage("..domain..")
.should().accessClassesThat().resideInAnyPackage("..spring..", "..infrastructure..");- Entity Exposure Check: Verify JPA entities are never returned from domain services
- Transaction Check: Confirm
@Transactional only on application layer services, never on domain
9. Write Tests
- Domain Tests: Pure unit tests without Spring context, fast execution
- Application Tests: Unit tests with mocked ports using Mockito
- Infrastructure Tests: Integration tests with
@DataJpaTest and Testcontainers - Adapter Tests: Controller tests with
@WebMvcTest
Examples
Example 1: Domain Layer - Entity with Domain Events
// domain/model/Order.java
public class Order {
private final OrderId id;
private final List<OrderItem> items;
private Money total;
private OrderStatus status;
private final List<DomainEvent> domainEvents = new ArrayList<>();
private Order(OrderId id, List<OrderItem> items) {
this.id = id;
this.items = new ArrayList<>(items);
this.status = OrderStatus.PENDING;
calculateTotal();
}
public static Order create(List<OrderItem> items) {
validateItems(items);
Order order = new Order(OrderId.generate(), items);
order.domainEvents.add(new OrderCreatedEvent(order.id, order.total));
return order;
}
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new DomainException("Only pending orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
}
public List<DomainEvent> getDomainEvents() {
return List.copyOf(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}Example 2: Domain Layer - Value Object with Validation
// domain/model/Money.java (Value Object)
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new DomainException("Amount cannot be negative");
}
}
public static Money zero() {
return new Money(BigDecimal.ZERO, Currency.getInstance("EUR"));
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new DomainException("Currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
}Example 3: Domain Layer - Repository Port
// domain/repository/OrderRepository.java (Port)
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
}Example 4: Application Layer - Use Case and Service
// application/port/in/CreateOrderUseCase.java
public interface CreateOrderUseCase {
OrderResponse createOrder(CreateOrderRequest request);
}
// application/dto/CreateOrderRequest.java
public record CreateOrderRequest(
@NotNull UUID customerId,
@NotEmpty List<OrderItemRequest> items
) {}
// application/service/OrderService.java
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final DomainEventPublisher eventPublisher;
@Override
public OrderResponse createOrder(CreateOrderRequest request) {
List<OrderItem> items = mapItems(request.items());
Order order = Order.create(items);
PaymentResult payment = paymentGateway.charge(order.getTotal());
if (!payment.successful()) {
throw new PaymentFailedException("Payment failed");
}
order.confirm();
Order saved = orderRepository.save(order);
publishEvents(order);
return OrderMapper.toResponse(saved);
}
private void publishEvents(Order order) {
order.getDomainEvents().forEach(eventPublisher::publish);
order.clearDomainEvents();
}
}Example 5: Infrastructure Layer - JPA Entity and Adapter
// infrastructure/persistence/OrderJpaEntity.java
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
@Id
private UUID id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private BigDecimal totalAmount;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItemJpaEntity> items;
}
// infrastructure/persistence/OrderRepositoryAdapter.java
@Component
@RequiredArgsConstructor
public class OrderRepositoryAdapter implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderJpaMapper mapper;
@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toEntity(order);
return mapper.toDomain(jpaRepository.save(entity));
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value()).map(mapper::toDomain);
}
}Example 6: Adapter Layer - REST Controller
// adapter/rest/OrderController.java
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
OrderResponse response = createOrderUseCase.createOrder(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.id())
.toUri();
return ResponseEntity.created(location).body(response);
}
}Example 7: Domain Tests (No Spring Context)
class OrderTest {
@Test
void shouldCreateOrderWithValidItems() {
List<OrderItem> items = List.of(
new OrderItem(new ProductId(UUID.randomUUID()), 2, new Money("10.00", EUR))
);
Order order = Order.create(items);
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(order.getDomainEvents()).hasSize(1);
}
}Example 8: Application Tests (Unit with Mocks)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository orderRepository;
@Mock PaymentGateway paymentGateway;
@Mock DomainEventPublisher eventPublisher;
@InjectMocks OrderService orderService;
@Test
void shouldCreateAndConfirmOrder() {
when(paymentGateway.charge(any())).thenReturn(new PaymentResult(true, "tx-123"));
when(orderRepository.save(any())).thenAnswer(i -> i.getArgument(0));
OrderResponse response = orderService.createOrder(createRequest());
assertThat(response.status()).isEqualTo(OrderStatus.CONFIRMED);
verify(eventPublisher).publish(any(OrderCreatedEvent.class));
}
}Best Practices
- Domain purity: Keep the domain layer free of Spring annotations and framework imports — zero dependencies on outer layers
- Feature-based packages: Organize by business capability (
order/,customer/) rather than technical role, with each feature containing all four layers - Immutable value objects: Use Java records for value objects with built-in validation in compactors — immutable by design
- Rich domain models: Place business logic in entities and aggregates, not in application services — services orchestrate, entities encapsulate
- Always map: Separate JPA entities from domain models using MapStruct or manual mappers; never expose JPA entities outside infrastructure
- Domain events for decoupling: Use
DomainEventPublisherto decouple cross-aggregate side effects instead of direct service calls - Transaction boundaries in application layer: Place
@Transactionalonly on application services, never on domain classes - Factory methods for invariants: Use
Entity.create(...)static methods to enforce invariants at construction time - Enforce with ArchUnit: Add ArchUnit tests in the test suite to verify no Spring or infrastructure imports reach the domain layer
- Strongly-typed IDs: Use
record OrderId(UUID value)instead of rawUUIDto prevent ID confusion across aggregates
Constraints and Warnings
Critical Constraints
- Domain Layer Purity: Never add Spring annotations (
@Entity,@Autowired,@Component) to domain classes - Dependency Direction: Dependencies must only point inward (domain <- application <- infrastructure/adapter)
- Framework Isolation: All framework-specific code must stay in infrastructure and adapter layers
Common Pitfalls to Avoid
- Anemic Domain Model: Entities with only getters/setters, logic in services - place business logic in entities
- Framework Leakage:
@Entity,@Autowiredin domain layer - keep domain framework-free - Lazy Loading Issues: Exposing JPA entities through domain model - use mappers to convert
- Circular Dependencies: Between domain aggregates - use IDs instead of direct references
- Missing Domain Events: Direct service calls instead of events for cross-aggregate communication
- Repository Misplacement: Defining repository interfaces in infrastructure - they belong in domain
- DTO Bypass: Exposing domain entities directly in API - always use DTOs for external contracts
Performance Considerations
- Separate JPA entities from domain models to avoid lazy loading issues
- Use read-only transactions for query operations
- Consider CQRS for complex read/write scenarios
References
references/java-clean-architecture.md- Java-specific patterns (records, sealed classes, strongly-typed IDs)references/spring-boot-implementation.md- Spring Boot integration (DI patterns, JPA mapping, transaction management)
Java Clean Architecture Patterns
Specific patterns for implementing Clean Architecture in Java 21+ applications.
Record Types for Value Objects
Java records provide immutability and automatic equals/hashCode implementation.
// Value object with validation
public record Email(String value) {
private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");
public Email {
if (value == null || !PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid email: " + value);
}
}
public String domain() {
return value.substring(value.indexOf('@') + 1);
}
}
// Complex value object with multiple fields
public record Address(
String street,
String city,
String postalCode,
String country
) {
public Address {
Objects.requireNonNull(street, "Street is required");
Objects.requireNonNull(city, "City is required");
}
public String formatted() {
return String.format("%s, %s %s, %s", street, city, postalCode, country);
}
}Sealed Classes for Domain Events
Use sealed classes to control event inheritance.
public sealed interface DomainEvent {
Instant occurredAt();
String aggregateId();
}
public record OrderCreatedEvent(
OrderId orderId,
Money total,
Instant occurredAt
) implements DomainEvent {
public OrderCreatedEvent {
Objects.requireNonNull(orderId);
Objects.requireNonNull(total);
Objects.requireNonNull(occurredAt);
}
@Override
public String aggregateId() {
return orderId.value().toString();
}
}
public record OrderConfirmedEvent(
OrderId orderId,
Instant confirmedAt
) implements DomainEvent {}Strongly-Typed IDs
Prevent ID confusion with type-safe wrappers.
public record OrderId(UUID value) {
public OrderId {
Objects.requireNonNull(value, "OrderId cannot be null");
}
public static OrderId generate() {
return new OrderId(UUID.randomUUID());
}
public static OrderId fromString(String id) {
return new OrderId(UUID.fromString(id));
}
}
public record CustomerId(UUID value) {
public CustomerId {
Objects.requireNonNull(value);
}
}Factory Methods in Entities
Centralize creation logic and enforce invariants.
public class Product {
private final ProductId id;
private String name;
private String description;
private Money price;
private Stock stock;
// Private constructor - use factory methods
private Product(ProductId id, String name, Money price) {
this.id = id;
this.name = name;
this.price = price;
this.stock = Stock.zero();
}
public static Product create(String name, Money price) {
validateName(name);
validatePrice(price);
return new Product(ProductId.generate(), name, price);
}
public static Product reconstitute(ProductId id, String name, Money price, Stock stock) {
Product product = new Product(id, name, price);
product.stock = stock;
return product;
}
private static void validateName(String name) {
if (name == null || name.isBlank() || name.length() > 100) {
throw new DomainException("Product name must be 1-100 characters");
}
}
public void updatePrice(Money newPrice) {
if (newPrice.amount().compareTo(BigDecimal.ZERO) <= 0) {
throw new DomainException("Price must be positive");
}
this.price = newPrice;
}
}Result Type for Operations
Explicit handling of success/failure without exceptions.
public sealed interface Result<T, E> {
record Success<T, E>(T value) implements Result<T, E> {}
record Failure<T, E>(E error) implements Result<T, E> {}
default boolean isSuccess() {
return this instanceof Success;
}
default Optional<T> getValue() {
return this instanceof Success<T, E> s ? Optional.of(s.value()) : Optional.empty();
}
}
// Usage in domain
public Result<Order, OrderError> confirm() {
if (status != OrderStatus.PENDING) {
return new Result.Failure<>(OrderError.ALREADY_CONFIRMED);
}
if (items.isEmpty()) {
return new Result.Failure<>(OrderError.EMPTY_ORDER);
}
this.status = OrderStatus.CONFIRMED;
return new Result.Success<>(this);
}Builder Pattern for Complex Aggregates
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private ShippingAddress shippingAddress;
private PaymentMethod paymentMethod;
private Order(Builder builder) {
this.id = builder.id;
this.customerId = builder.customerId;
this.items = List.copyOf(builder.items);
this.shippingAddress = builder.shippingAddress;
this.paymentMethod = builder.paymentMethod;
}
public static Builder builder(CustomerId customerId) {
return new Builder(customerId);
}
public static class Builder {
private OrderId id = OrderId.generate();
private final CustomerId customerId;
private List<OrderItem> items = new ArrayList<>();
private ShippingAddress shippingAddress;
private PaymentMethod paymentMethod;
private Builder(CustomerId customerId) {
this.customerId = Objects.requireNonNull(customerId);
}
public Builder withItem(ProductId product, int quantity, Money price) {
items.add(new OrderItem(product, quantity, price));
return this;
}
public Builder shippingTo(ShippingAddress address) {
this.shippingAddress = address;
return this;
}
public Builder paidWith(PaymentMethod method) {
this.paymentMethod = method;
return this;
}
public Order build() {
if (items.isEmpty()) {
throw new DomainException("Order must have at least one item");
}
return new Order(this);
}
}
}
// Usage
Order order = Order.builder(customerId)
.withItem(product1, 2, new Money("29.99", EUR))
.withItem(product2, 1, new Money("49.99", EUR))
.shippingTo(new ShippingAddress("123 Main St", "City", "12345"))
.paidWith(PaymentMethod.CREDIT_CARD)
.build();Domain Service Pattern
When logic doesn't belong to a single entity.
// Domain service interface
public interface PricingService {
Money calculateTotal(List<OrderItem> items, CustomerType customerType);
}
// Domain service implementation (still framework-free)
public class StandardPricingService implements PricingService {
private static final BigDecimal VIP_DISCOUNT = new BigDecimal("0.90");
@Override
public Money calculateTotal(List<OrderItem> items, CustomerType customerType) {
Money subtotal = items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.zero(), Money::add);
return customerType == CustomerType.VIP
? subtotal.multiply(VIP_DISCOUNT)
: subtotal;
}
}Specification Pattern
Encapsulate business rules as composable objects.
public interface Specification<T> {
boolean isSatisfiedBy(T candidate);
default Specification<T> and(Specification<T> other) {
return candidate -> this.isSatisfiedBy(candidate) && other.isSatisfiedBy(candidate);
}
default Specification<T> not() {
return candidate -> !this.isSatisfiedBy(candidate);
}
}
// Usage
public class OrderSpecifications {
public static Specification<Order> isPending() {
return order -> order.getStatus() == OrderStatus.PENDING;
}
public static Specification<Order> hasMinimumValue(Money minimum) {
return order -> order.getTotal().amount().compareTo(minimum.amount()) >= 0;
}
public static Specification<Order> isEligibleForAutoApproval() {
return isPending().and(hasMinimumValue(new Money("1000.00", EUR))).not();
}
}Thread-Safe Domain Events
public abstract class AggregateRoot {
private final List<DomainEvent> domainEvents = new CopyOnWriteArrayList<>();
protected void registerEvent(DomainEvent event) {
domainEvents.add(event);
}
public List<DomainEvent> getDomainEvents() {
return List.copyOf(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}Spring Boot Implementation Guide
Detailed patterns for integrating Clean Architecture with Spring Boot 3.5+.
Configuration Structure
// Application configuration
@Configuration
@ComponentScan(basePackages = "com.example.order")
@EnableJpaRepositories(basePackages = "com.example.order.infrastructure.persistence")
@EntityScan(basePackages = "com.example.order.infrastructure.persistence")
public class OrderConfiguration {
@Bean
public OrderService orderService(
OrderRepository orderRepository,
PaymentGateway paymentGateway,
DomainEventPublisher eventPublisher) {
return new OrderService(orderRepository, paymentGateway, eventPublisher);
}
}Dependency Injection Patterns
Constructor Injection (Preferred)
@Service
@RequiredArgsConstructor
public class OrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final DomainEventPublisher eventPublisher;
}Multiple Implementations with Qualifier
// Port interface
public interface NotificationService {
void sendNotification(String to, String subject, String body);
}
// Primary adapter
@Component
@Primary
public class EmailNotificationService implements NotificationService {
private final JavaMailSender mailSender;
// Implementation
}
// Secondary adapter
@Component
@Qualifier("sms")
public class SmsNotificationService implements NotificationService {
private final SmsClient smsClient;
// Implementation
}
// Usage
@Service
@RequiredArgsConstructor
public class OrderConfirmationService {
private final NotificationService emailNotification; // @Primary injected
private final @Qualifier("sms") NotificationService smsNotification;
}Conditional Beans
@Component
@ConditionalOnProperty(name = "payment.provider", havingValue = "stripe")
public class StripePaymentAdapter implements PaymentGateway {
// Stripe implementation
}
@Component
@ConditionalOnProperty(name = "payment.provider", havingValue = "paypal")
public class PayPalPaymentAdapter implements PaymentGateway {
// PayPal implementation
}JPA Mapping Strategies
Separate Entity and Domain Model
// Domain model (in domain package)
public class Order {
private final OrderId id;
private OrderStatus status;
private List<OrderItem> items;
// Pure business logic, no annotations
}
// JPA entity (in infrastructure package)
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
@Id
private UUID id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private List<OrderItemJpaEntity> items;
@Column(name = "created_at")
private Instant createdAt;
}Mapper with MapStruct
@Mapper(componentModel = "spring")
public interface OrderJpaMapper {
OrderJpaEntity toEntity(Order order);
Order toDomain(OrderJpaEntity entity);
default UUID map(OrderId id) {
return id != null ? id.value() : null;
}
default OrderId map(UUID id) {
return id != null ? new OrderId(id) : null;
}
default String map(Money money) {
return money != null ? money.amount().toString() : null;
}
default Money map(String amount, String currency) {
return amount != null ? new Money(new BigDecimal(amount), Currency.getInstance(currency)) : null;
}
}Transaction Management
Application Service Boundary
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final PaymentGateway paymentGateway;
@Transactional
public Order createOrder(CreateOrderCommand command) {
// All operations within same transaction
Order order = Order.create(command.customerId(), command.items());
inventoryService.reserve(order.getItems()); // Will rollback if fails
PaymentResult payment = paymentGateway.charge(order.getTotal());
if (!payment.successful()) {
throw new PaymentFailedException();
}
return orderRepository.save(order);
}
@Transactional(readOnly = true)
public Optional<Order> findOrder(OrderId id) {
return orderRepository.findById(id);
}
}Read-Only Transactions for Queries
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderQueryService {
private final OrderJpaRepository orderJpaRepository;
private final OrderMapper mapper;
public Page<OrderSummary> findOrders(OrderSearchCriteria criteria, Pageable pageable) {
return orderJpaRepository.findByCriteria(criteria, pageable)
.map(mapper::toSummary);
}
}Domain Events Publishing
Spring ApplicationEventPublisher Adapter
@Component
@RequiredArgsConstructor
public class SpringDomainEventPublisher implements DomainEventPublisher {
private final ApplicationEventPublisher publisher;
@Override
public void publish(DomainEvent event) {
publisher.publishEvent(event);
}
}
// Or with @TransactionalEventListener for after-commit
@Component
@RequiredArgsConstructor
public class OrderEventListener {
private final EmailService emailService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
emailService.sendOrderConfirmation(event.orderId());
}
@EventListener
public void handleOrderCancelled(OrderCancelledEvent event) {
// Handle synchronously
}
}Validation
Bean Validation in Application Layer
public record CreateOrderRequest(
@NotNull(message = "Customer ID is required")
UUID customerId,
@NotEmpty(message = "Order must have at least one item")
@Valid
List<@NotNull OrderItemRequest> items,
@Valid
ShippingAddressRequest shippingAddress
) {}
public record OrderItemRequest(
@NotNull
UUID productId,
@Min(value = 1, message = "Quantity must be at least 1")
@Max(value = 100, message = "Maximum quantity is 100")
int quantity
) {}Custom Validator
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidOrderIdValidator.class)
public @interface ValidOrderId {
String message() default "Invalid order ID";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class ValidOrderIdValidator implements ConstraintValidator<ValidOrderId, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true;
try {
UUID.fromString(value);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
}Exception Handling
Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomainException(DomainException ex) {
ErrorResponse error = new ErrorResponse(
ex.getCode(),
ex.getMessage(),
Instant.now()
);
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
.map(error -> new FieldError(
error.getField(),
error.getDefaultMessage()
))
.toList();
return ResponseEntity.badRequest()
.body(new ValidationErrorResponse(errors));
}
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage(), Instant.now()));
}
}Testing Configuration
Slice Tests for Adapters
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryAdapterTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderJpaRepository jpaRepository;
@Autowired
private OrderJpaMapper mapper;
private OrderRepositoryAdapter adapter;
@BeforeEach
void setUp() {
adapter = new OrderRepositoryAdapter(jpaRepository, mapper);
}
@Test
void shouldSaveAndRetrieveOrder() {
Order order = Order.create(new CustomerId(UUID.randomUUID()), sampleItems());
Order saved = adapter.save(order);
Optional<Order> found = adapter.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getId()).isEqualTo(saved.getId());
}
}WebMvcTest for Controllers
@WebMvcTest(OrderController.class)
@Import({OrderMapperImpl.class, GlobalExceptionHandler.class})
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private CreateOrderUseCase createOrderUseCase;
@MockitoBean
private GetOrderUseCase getOrderUseCase;
@Test
void shouldCreateOrder() throws Exception {
when(createOrderUseCase.createOrder(any()))
.thenReturn(new OrderResponse(UUID.randomUUID(), OrderStatus.PENDING));
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"customerId": "550e8400-e29b-41d4-a716-446655440000",
"items": [{"productId": "550e8400-e29b-41d4-a716-446655440001", "quantity": 2}]
}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("PENDING"));
}
}OpenAPI Documentation
@Tag(name = "Orders", description = "Order management endpoints")
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
@Operation(summary = "Create new order")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Order created successfully"),
@ApiResponse(responseCode = "400", description = "Invalid request"),
@ApiResponse(responseCode = "422", description = "Business rule violation")
})
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody @Parameter(description = "Order creation request") CreateOrderRequest request) {
// Implementation
}
}Profiles for Environment-Specific Adapters
@Component
@Profile("!test")
public class SmtpEmailService implements EmailService {
// Real SMTP implementation
}
@Component
@Profile("test")
public class InMemoryEmailService implements EmailService {
private final List<EmailMessage> sentEmails = new ArrayList<>();
@Override
public void send(String to, String subject, String body) {
sentEmails.add(new EmailMessage(to, subject, body));
}
public List<EmailMessage> getSentEmails() {
return List.copyOf(sentEmails);
}
}