| name | domain-driven-design |
| description | Use when working with domain models, aggregates, value objects, domain events, or repositories in a DDD-style project. Ensures rich domain model over anemic CRUD.
|
Domain-Driven Design
Aggregate Rules
- One repository per aggregate root
- External code only accesses aggregate through root — never child entities directly
- Aggregates reference other aggregates by ID only, not direct object reference
- Keep aggregates small — if it has more than 3-4 child entities, split it
order.addItem(productId, quantity);
order.removeItem(itemId);
order.getItems().add(new OrderItem(...));
Value Objects
Immutable, no identity, equality by value:
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Amount cannot be negative");
Objects.requireNonNull(currency);
}
public Money add(Money other) {
if (!currency.equals(other.currency))
throw new CurrencyMismatchException(currency, other.currency);
return new Money(amount.add(other.amount), currency);
}
public static Money of(String amount, String currency) {
return new Money(new BigDecimal(amount), Currency.getInstance(currency));
}
}
public record EmailAddress(String value) {
public EmailAddress {
if (!value.matches("^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$"))
throw new InvalidEmailException(value);
}
}
Domain Events
public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant occurredAt) {
public static OrderPlaced of(Order order) {
return new OrderPlaced(order.getId(), order.getCustomerId(), order.getTotal(), Instant.now());
}
}
@Entity
public class Order {
@Transient
private final List<Object> domainEvents = new ArrayList<>();
public void place() {
this.status = OrderStatus.PLACED;
domainEvents.add(OrderPlaced.of(this));
}
public List<Object> pullDomainEvents() {
var events = List.copyOf(domainEvents);
domainEvents.clear();
return events;
}
}
@Service
@RequiredArgsConstructor
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order placeOrder(PlaceOrderCommand command) {
Order order = orderRepository.findById(command.orderId()).orElseThrow();
order.place();
Order saved = orderRepository.save(order);
saved.pullDomainEvents().forEach(eventPublisher::publishEvent);
return saved;
}
}
@Component
@RequiredArgsConstructor
public class OrderPlacedHandler {
private final EmailService emailService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void onOrderPlaced(OrderPlaced event) {
emailService.sendOrderConfirmation(event.customerId(), event.orderId());
}
}
Let Spring Data publish for you. Instead of calling pullDomainEvents() by hand, expose a
@DomainEvents method (returns the collected events) and an @AfterDomainEventPublication method
(clears them) on the aggregate root. Spring Data's repository drains and publishes them automatically
on every save() — no manual wiring in the service.
Specifications (complex queries)
public class OrderSpecifications {
public static Specification<Order> byStatus(OrderStatus status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Order> byCustomer(UUID customerId) {
return (root, query, cb) -> cb.equal(root.get("customerId"), customerId);
}
public static Specification<Order> placedAfter(Instant date) {
return (root, query, cb) -> cb.greaterThan(root.get("placedAt"), date);
}
}
Specification<Order> spec = OrderSpecifications.byStatus(PLACED)
.and(OrderSpecifications.byCustomer(customerId))
.and(OrderSpecifications.placedAfter(lastWeek));
orderRepository.findAll(spec, pageable);
Anti-Corruption Layer (ACL)
- When integrating with external systems or legacy code, don't let their models leak into your domain
- Create an ACL — a translation layer that converts external data to your domain language
- ACL lives in infrastructure layer, not domain
@Component
@RequiredArgsConstructor
public class PaymentGatewayAdapter implements PaymentPort {
private final ExternalPaymentClient client;
@Override
public PaymentConfirmation charge(OrderId orderId, Money amount) {
PaymentApiRequest apiRequest = new PaymentApiRequest(
orderId.value().toString(),
amount.amount().doubleValue(),
amount.currency().getCurrencyCode());
PaymentApiResponse apiResponse = client.charge(apiRequest);
return new PaymentConfirmation(
PaymentId.of(apiResponse.getTransactionId()),
apiResponse.isSuccessful() ? PaymentStatus.CONFIRMED : PaymentStatus.DECLINED);
}
}
Gotchas
- Agent creates anemic models with only getters/setters — put behavior on domain objects
- Agent uses
Long for entity IDs — use typed value objects (OrderId, CustomerId)
- Agent puts domain logic in services — services should orchestrate, not decide
- Agent accesses child entities directly from outside — always go through aggregate root
- Agent publishes events before saving — publish after successful save/commit
- Agent lets external API models into domain — use an Anti-Corruption Layer to translate
- Agent leaves nullability implicit in domain APIs — Framework 7 is JSpecify
@NullMarked; do the same for domain packages and mark the rare nullable return with org.jspecify.annotations.Nullable
- Agent mocks repositories with
@MockBean in application-service tests — removed in Boot 4; use @MockitoBean