| name | ddd-java |
| description | Domain-Driven Design tactical patterns for Java 25+. Value Objects, Entities, Aggregates, Domain Services, Domain Events, Ubiquitous Language, and Bounded Contexts. Use when modeling domain logic in Java Spring Boot services. |
Domain-Driven Design for Java
Tactical DDD patterns for rich, behavior-driven domain models.
vs hexagonal-java: This skill focuses on domain modeling — Value Objects, Entities, Aggregates, Domain Events, and Ubiquitous Language. Use hexagonal-java when you need package structure and dependency direction — how to organize ports, adapters, and use case classes in Spring Boot.
When to Activate
- Modeling a new domain concept (what is this thing? entity or value object?)
- Deciding whether logic belongs in domain, use case, or adapter
- Identifying aggregate boundaries and consistency rules
- Designing domain events and their dispatch
- Reviewing for anemic domain model (data containers with no behavior)
- Naming classes, methods, and packages (ubiquitous language)
- Planning a multi-service system with bounded contexts
Building Block 1: Value Objects
Identity: none — equal when all fields are equal.
Rule: Immutable. No setters. Use records or final classes.
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "amount required");
Objects.requireNonNull(currency, "currency required");
if (amount.compareTo(BigDecimal.ZERO) < 0)
throw new InvalidMoneyException("amount must be non-negative");
}
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new CurrencyMismatchException(this.currency, other.currency);
return new Money(this.amount.add(other.amount), this.currency);
}
public boolean isZero() {
return amount.compareTo(BigDecimal.ZERO) == 0;
}
}
public record MarketId(Long value) {
public MarketId { Objects.requireNonNull(value, "id required"); }
}
Common Value Objects: Money, Email, PhoneNumber, Address, DateRange, typed IDs (MarketId, UserId), Percentage, Quantity.
Building Block 2: Entities
Identity: defined by a unique ID — two entities with the same ID are the same object, regardless of field values.
Rule: Has behavior (domain methods), not just data. Keep mutable state minimal and guarded.
public class Market {
private final MarketId id;
private String name;
private MarketStatus status;
private final List<DomainEvent> domainEvents = new ArrayList<>();
private Market(MarketId id, String name, MarketStatus status) {
this.id = id;
this.name = name;
this.status = status;
}
public static Market create(String name) {
if (name == null || name.isBlank()) throw new InvalidMarketException("name required");
return new Market(null, name, MarketStatus.DRAFT);
}
public void publish() {
if (this.status != MarketStatus.DRAFT)
throw new MarketAlreadyPublishedException(id);
this.status = MarketStatus.ACTIVE;
domainEvents.add(new MarketPublishedEvent(id, name));
}
{
( == o) ;
(!(o Market m)) ;
id != && id.equals(m.id);
}
{ Objects.hashCode(id); }
MarketId { id; }
String { name; }
MarketStatus { status; }
List<DomainEvent> {
List.copyOf(domainEvents);
domainEvents.clear();
events;
}
}
Building Block 3: Aggregates & Aggregate Root
An Aggregate is a cluster of domain objects (entities + value objects) treated as a unit for data changes.
The Aggregate Root is the only entry point — external code never holds references to internal entities directly.
Rules
- One transaction = one aggregate — never modify two aggregates in one transaction
- Reference other aggregates by ID only — never by object reference
- Repository per Aggregate Root — no repository for child entities
- Invariants are enforced inside the aggregate — the root ensures the cluster is always consistent
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines;
private OrderStatus status;
private Order(OrderId id, CustomerId customerId) {
this.id = id;
this.customerId = customerId;
this.lines = new ArrayList<>();
this.status = OrderStatus.DRAFT;
}
public static Order create(CustomerId customerId) {
return new Order(null, Objects.requireNonNull(customerId));
}
public void addLine(ProductId productId, Quantity quantity, Money unitPrice) {
if (status != OrderStatus.DRAFT)
throw new OrderAlreadyPlacedException(id);
lines.add(new OrderLine(productId, quantity, unitPrice));
}
public Money totalPrice() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(Money.zero(Currency.EUR), Money::add);
}
{
(lines.isEmpty()) (id);
.status = OrderStatus.PLACED;
}
List<OrderLine> { Collections.unmodifiableList(lines); }
}
{
ProductId productId;
Quantity quantity;
Money unitPrice;
OrderLine(ProductId productId, Quantity quantity, Money unitPrice) {
.productId = productId;
.quantity = quantity;
.unitPrice = unitPrice;
}
Money {
unitPrice.multiply(quantity.value());
}
}
Building Block 4: Domain Services
When: Logic belongs in the domain but doesn't naturally fit a single entity or value object.
Rule: Stateless. No Spring annotations (@Service belongs in adapters). Named after domain verbs.
public class PricingPolicy {
public Money calculateFinalPrice(Order order, DiscountCode discountCode) {
Money base = order.totalPrice();
if (discountCode.isValid() && discountCode.appliesTo(order)) {
return base.subtract(discountCode.discountAmount(base));
}
return base;
}
}
@Bean
PricingPolicy pricingPolicy() { return new PricingPolicy(); }
Domain Service vs Application Service:
| Domain Service | Application Service (Use Case) |
|---|
| Location | domain/service/ | application/usecase/ |
| Depends on | Domain model only | Ports (in + out), domain service |
Has @Transactional | Never | Yes |
| Has Spring annotations | Never | Can (via config) |
| Example | PricingPolicy, TransferPolicy | CreateOrderUseCase, PlaceOrderService |
Building Block 5: Domain Events
Domain events represent something that happened in the domain. They are immutable facts.
public interface DomainEvent {
Instant occurredAt();
}
public record MarketPublishedEvent(
MarketId marketId,
String name,
Instant occurredAt
) implements DomainEvent {
public MarketPublishedEvent(MarketId marketId, String name) {
this(marketId, name, Instant.now());
}
}
Dispatching Domain Events (Spring Events pattern)
@Transactional
public class PublishMarketService implements PublishMarketUseCase {
private final MarketRepository marketRepository;
private final ApplicationEventPublisher eventPublisher;
@Override
public void publish(MarketId marketId) {
var market = marketRepository.findById(marketId)
.orElseThrow(() -> new MarketNotFoundException(marketId));
market.publish();
marketRepository.save(market);
market.pullDomainEvents().forEach(eventPublisher::publishEvent);
}
}
@Component
class MarketEventListener {
@EventListener
void on(MarketPublishedEvent event) {
}
}
Ubiquitous Language
Use the same terms in code as domain experts use in conversation. Never translate between domain language and technical language.
public class MarketProcessor {
public MarketData processMarketData(MarketDataInput input) {}
}
public class Market {
public void publish() {}
public void suspend(SuspensionReason reason) {}
public void resolve(ResolutionOutcome outcome) {}
}
Enforce in code reviews: If a domain expert wouldn't recognize a term, rename it.
Bounded Contexts
A Bounded Context is an explicit boundary within which a domain model is defined and applicable.
Each microservice should typically correspond to one Bounded Context.
@startuml
!include <C4/C4_Container>
System_Boundary(oc, "Order Context") {
Container(oc_ord, "Order", "Aggregate Root", "")
Container(oc_cust, "Customer", "by ID ref", "")
Container(oc_prod, "Product", "by ID ref", "")
Container(oc_line, "OrderLine", "Entity", "")
}
System_Boundary(pc, "Payment Context") {
Container(pc_inv, "Invoice", "Aggregate Root", "")
Container(pc_cust, "Customer", "different model!", "")
Container(pc_pm, "PaymentMethod", "Entity", "")
}
@enduml
Context Mapping (anti-corruption layer between contexts)
When Context A calls Context B, translate at the boundary — don't leak B's model into A:
@Component
class PaymentContextAdapter implements PaymentPort {
private final PaymentApiClient paymentApiClient;
@Override
public PaymentResult initiatePayment(Order order, Money amount) {
var request = new PaymentApiRequest(
order.id().value().toString(),
amount.amount(),
amount.currency().getCurrencyCode()
);
var response = paymentApiClient.charge(request);
return new PaymentResult(response.isSuccess(), response.transactionId());
}
}
Anti-Patterns to Avoid
Anemic Domain Model
public class Market {
private String status;
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
public class PublishMarketService {
public void publish(Long id) {
var market = repo.findById(id);
if (!market.getStatus().equals("DRAFT"))
throw new IllegalStateException();
market.setStatus("ACTIVE");
repo.save(market);
}
}
public class Market {
public void publish() {
if (status != DRAFT) throw new MarketAlreadyPublishedException(id);
this.status = ACTIVE;
}
}
Primitive Obsession
void createOrder(String userId, String marketId, BigDecimal amount) {}
void createOrder(UserId userId, MarketId marketId, Money amount) {}
Repository per Entity (not per Aggregate Root)
orderLineRepository.save(orderLine);
order.addLine(productId, quantity, price);
orderRepository.save(order);
DDD Checklist for New Projects
DDD Checklist for Existing Projects (Refactoring)
Reference
- Strategic DDD (Bounded Contexts, Context Map, Subdomain classification, Event Storming): see skill
strategic-ddd
- Hexagonal Architecture (package structure, adapters): see skill
hexagonal-java
- Spring Boot wiring: see skill
springboot-patterns
- JPA persistence adapter patterns: see skill
jpa-patterns