rrezartprebreza/spring-boot-domain-driven-design
> 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.
npx skills add https://github.com/rrezartprebreza/spring-boot-skills --skill domain-driven-design
// ✅ Aggregate root controls all access to children
order.addItem(productId, quantity); // through root
order.removeItem(itemId); // through root
// ❌ Direct child access from outside
order.getItems().add(new OrderItem(...)); // bypasses invariants
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);
}
}
// Event — immutable record
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());
}
}
// Collect events in aggregate, publish after save
@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;
}
}
// Publish after successful save
@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); // publish after commit
return saved;
}
}
// Listen to events — bind to commit, not just publish.
// @EventListener fires synchronously inside the TX; if the TX later rolls back you've
// already sent the email. Prefer @TransactionalEventListener(AFTER_COMMIT) — see [[transactional-patterns]].
@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.
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);
}
}
// Compose
Specification<Order> spec = OrderSpecifications.byStatus(PLACED)
.and(OrderSpecifications.byCustomer(customerId))
.and(OrderSpecifications.placedAfter(lastWeek));
orderRepository.findAll(spec, pageable);
// ✅ GOOD — ACL translates external payment API to domain concepts
@Component
@RequiredArgsConstructor
public class PaymentGatewayAdapter implements PaymentPort {
private final ExternalPaymentClient client; // third-party SDK
@Override
public PaymentConfirmation charge(OrderId orderId, Money amount) {
// Translate domain → external
PaymentApiRequest apiRequest = new PaymentApiRequest(
orderId.value().toString(),
amount.amount().doubleValue(),
amount.currency().getCurrencyCode());
// Call external system
PaymentApiResponse apiResponse = client.charge(apiRequest);
// Translate external → domain
return new PaymentConfirmation(
PaymentId.of(apiResponse.getTransactionId()),
apiResponse.isSuccessful() ? PaymentStatus.CONFIRMED : PaymentStatus.DECLINED);
}
}
Long for entity IDs — use typed value objects (OrderId, CustomerId)@NullMarked; do the same for domain packages and mark the rare nullable return with org.jspecify.annotations.Nullable@MockBean in application-service tests — removed in Boot 4; use @MockitoBeanTake rrezartprebreza/spring-boot-domain-driven-design from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.