$npx -y skills add rrezartprebreza/spring-boot-skills --skill domain-driven-designUse when working with domain models, aggregates, value objects, domain events, or repositories in a DDD-style project. Ensures rich domain model over anemic CRUD.
| 1 | # Domain-Driven Design |
| 2 | |
| 3 | ## Aggregate Rules |
| 4 | - One repository per aggregate root |
| 5 | - External code only accesses aggregate through root — never child entities directly |
| 6 | - Aggregates reference other aggregates by ID only, not direct object reference |
| 7 | - Keep aggregates small — if it has more than 3-4 child entities, split it |
| 8 | |
| 9 | ```java |
| 10 | // ✅ Aggregate root controls all access to children |
| 11 | order.addItem(productId, quantity); // through root |
| 12 | order.removeItem(itemId); // through root |
| 13 | |
| 14 | // ❌ Direct child access from outside |
| 15 | order.getItems().add(new OrderItem(...)); // bypasses invariants |
| 16 | ``` |
| 17 | |
| 18 | ## Value Objects |
| 19 | |
| 20 | Immutable, no identity, equality by value: |
| 21 | |
| 22 | ```java |
| 23 | public record Money(BigDecimal amount, Currency currency) { |
| 24 | public Money { |
| 25 | if (amount.compareTo(BigDecimal.ZERO) < 0) |
| 26 | throw new IllegalArgumentException("Amount cannot be negative"); |
| 27 | Objects.requireNonNull(currency); |
| 28 | } |
| 29 | |
| 30 | public Money add(Money other) { |
| 31 | if (!currency.equals(other.currency)) |
| 32 | throw new CurrencyMismatchException(currency, other.currency); |
| 33 | return new Money(amount.add(other.amount), currency); |
| 34 | } |
| 35 | |
| 36 | public static Money of(String amount, String currency) { |
| 37 | return new Money(new BigDecimal(amount), Currency.getInstance(currency)); |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | public record EmailAddress(String value) { |
| 42 | public EmailAddress { |
| 43 | if (!value.matches("^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$")) |
| 44 | throw new InvalidEmailException(value); |
| 45 | } |
| 46 | } |
| 47 | ``` |
| 48 | |
| 49 | ## Domain Events |
| 50 | |
| 51 | ```java |
| 52 | // Event — immutable record |
| 53 | public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant occurredAt) { |
| 54 | public static OrderPlaced of(Order order) { |
| 55 | return new OrderPlaced(order.getId(), order.getCustomerId(), order.getTotal(), Instant.now()); |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // Collect events in aggregate, publish after save |
| 60 | @Entity |
| 61 | public class Order { |
| 62 | @Transient |
| 63 | private final List<Object> domainEvents = new ArrayList<>(); |
| 64 | |
| 65 | public void place() { |
| 66 | this.status = OrderStatus.PLACED; |
| 67 | domainEvents.add(OrderPlaced.of(this)); |
| 68 | } |
| 69 | |
| 70 | public List<Object> pullDomainEvents() { |
| 71 | var events = List.copyOf(domainEvents); |
| 72 | domainEvents.clear(); |
| 73 | return events; |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // Publish after successful save |
| 78 | @Service |
| 79 | @RequiredArgsConstructor |
| 80 | public class OrderApplicationService { |
| 81 | private final OrderRepository orderRepository; |
| 82 | private final ApplicationEventPublisher eventPublisher; |
| 83 | |
| 84 | @Transactional |
| 85 | public Order placeOrder(PlaceOrderCommand command) { |
| 86 | Order order = orderRepository.findById(command.orderId()).orElseThrow(); |
| 87 | order.place(); |
| 88 | Order saved = orderRepository.save(order); |
| 89 | saved.pullDomainEvents().forEach(eventPublisher::publishEvent); // publish after commit |
| 90 | return saved; |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // Listen to events — bind to commit, not just publish. |
| 95 | // @EventListener fires synchronously inside the TX; if the TX later rolls back you've |
| 96 | // already sent the email. Prefer @TransactionalEventListener(AFTER_COMMIT) — see [[transactional-patterns]]. |
| 97 | @Component |
| 98 | @RequiredArgsConstructor |
| 99 | public class OrderPlacedHandler { |
| 100 | private final EmailService emailService; |
| 101 | |
| 102 | @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) |
| 103 | @Async |
| 104 | public void onOrderPlaced(OrderPlaced event) { |
| 105 | emailService.sendOrderConfirmation(event.customerId(), event.orderId()); |
| 106 | } |
| 107 | } |
| 108 | ``` |
| 109 | |
| 110 | > **Let Spring Data publish for you.** Instead of calling `pullDomainEvents()` by hand, expose a |
| 111 | > `@DomainEvents` method (returns the collected events) and an `@AfterDomainEventPublication` method |
| 112 | > (clears them) on the aggregate root. Spring Data's repository drains and publishes them automatically |
| 113 | > on every `save()` — no manual wiring in the service. |
| 114 | |
| 115 | ## Specifications (complex queries) |
| 116 | |
| 117 | ```java |
| 118 | public class OrderSpecifications { |
| 119 | public static Specification<Order> byStatus(OrderStatus status) { |
| 120 | return (root, query, cb) -> cb.equal(root.get("status"), status); |
| 121 | } |
| 122 | |
| 123 | public static Specification<Order> byCustomer(UUID customerId) { |
| 124 | return (root, query, cb) -> cb.equal(root.get("customerId"), customerId); |
| 125 | } |
| 126 | |
| 127 | public static Specification<Order> placedAfter(Instant date) { |
| 128 | return (root, query, cb) -> cb.greaterThan(root.get("placedAt"), date); |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // Compose |
| 133 | Specification<Order> spec = OrderSpecifications.byStatus(PLACED) |
| 134 | .and(OrderSpecifications.byCustomer(customerId)) |
| 135 | .and(OrderSpecifications.placedAfter(lastWeek)); |
| 136 | |
| 137 | orderRepository.findAll(spec, pageable); |
| 138 | ``` |
| 139 | |
| 140 | ## Anti-Corruption Layer (ACL) |
| 141 | - When integrating with external systems or legacy code, don't let their models leak into your domain |
| 142 | - Create an ACL — a translation layer that converts external data to your domain language |
| 143 | - ACL l |