In this guide (24 items)
Domain-Driven Design
Domain-Driven Design (DDD)
MANDATORY FOR ALL AI AGENTS: Every domain entity, value object, and aggregate MUST follow the DDD building blocks described here. Domain classes MUST NOT have framework annotations (no Spring, no JPA, no Kafka). Business logic MUST reside in domain entities, NOT in use cases or adapters. Use cases orchestrate domain entities but do NOT contain business rules.
1. General Concept
Domain-Driven Design (DDD) is a software design methodology focused on the business domain. Its goal is to align the technical model with business knowledge and rules, fostering close collaboration between developers and domain experts.
DDD promotes a shared understanding of the domain through a common language (Ubiquitous Language) and structures the software around business concepts rather than technical concerns.
2. Key Components
| Concept | Description |
|---|---|
| Domain | Core business knowledge and rules. |
| Ubiquitous Language | Shared language between business and development teams. |
| Model | Representation of the domain through entities, values, and services. |
| Bounded Context | Boundary within which a domain model is valid and consistent. |
| Context Map | Map of relationships between different bounded contexts. |
3. Bounded Context
A Bounded Context defines the boundaries within which a domain model is valid. Inside this boundary, terms, rules, and behaviors have a consistent meaning.
Each context can have its own model, database, and API, communicating with others through explicit contracts. In a microservices architecture, each microservice typically corresponds to a bounded context.
Rules:
- DO NOT share domain models across bounded contexts.
- Use Anticorruption Layers (ACL) when integrating with external contexts.
- Define explicit contracts (events, APIs) for inter-context communication.
4. Context Map Patterns
| Pattern | Description |
|---|---|
| Shared Kernel | Two contexts share a small common submodel. |
| Customer-Supplier | One context depends on and may influence another. |
| Conformist | The consumer adopts the provider’s model as-is. |
| Anticorruption Layer (ACL) | Translates models to avoid semantic contamination. |
| Open Host Service (OHS) | Public, standard API for multiple consumers. |
| Published Language | Shared language for interoperability (e.g., JSON Schema, Avro). |
| Separate Ways | Contexts evolve independently without integration. |
| Partnership | Two contexts evolve together in cooperation. |
5. Building Blocks
| Concept | Description | Example |
|---|---|---|
| Entity | Object with persistent identity. | Organization, User |
| Value Object | Immutable object without identity. | Money, Address, Email |
| Aggregate | Cohesive cluster of an entity root plus member entities/value objects, mutated only through the root. | Order (root) + OrderItem (member entity) + Money (VO) |
| Repository | Persistence abstraction for aggregates. | OrganizationRepositoryPort |
| Service (Domain Service) | Domain logic that does not naturally belong to an entity. | PricingService |
| Domain Event | Significant occurrence within the domain. | OrganizationCreated, UserEnabled |
| Factory | Encapsulates aggregate creation. Prefer a factory method on the aggregate (companion object / static create); reserve a separate Factory class for creation that spans multiple aggregates. |
Organization.create(...), OrderFactory |
| Command | Immutable intention object — Kotlin data class, Java record. Lives in the application layer. |
CreateOrganizationCommand |
6. Layers in DDD
This project maps DDD onto its real hexagonal convention (four modules, not the classic UI/API split):
domain– pure logic, no framework deps, immutable. Entities, value objects, core business rules.application– use cases + ports, depends on domain only, no annotations. Orchestrates use cases and transactions. Contains NO business logic.infrastructure– adapters. INCLUDES ALL entry-points (REST controllers, Kafka/RabbitMQ consumers, schedulers, CLI) as inbound adapters, plus the outbound adapters (DB, queues, HTTP clients). There is NO separate “User Interface” or “API Layer”.bootstrap– wiring Spring, deps de todos los módulos. No business logic.
Every entry-point (API/REST/messaging) is an inbound adapter inside infrastructure — NOT a separate layer.
Dependency rule: Dependencies MUST point inward. Domain depends on nothing. Application depends on Domain. Infrastructure depends on Application and Domain.
Mapping to this project’s hexagonal modules: UI/API + Infrastructure → infrastructure/ (adapters); Application → application/ (use cases and ports — the repository port is here, the adapter is in infrastructure/); Domain → domain/. Spring wiring lives in bootstrap/, which depends on all modules but contains no business logic. See hexagonal-architecture.md.
7. Benefits
- Aligns software with business needs.
- Improves communication between teams via Ubiquitous Language.
- Enables easier evolution and maintenance.
- Encourages autonomy per bounded context.
- Reduces coupling between domains.
8. Example Diagram (Mermaid)
graph TD A[Catalog Context] -->|Published Language| B[Shopping Cart Context] B -->|Customer-Supplier| C[Billing Context] C -->|Anticorruption Layer| D[CRM Context] D -->|Partnership| E[Customer Support] E -->|Shared Kernel| C F[Reporting Context] -->|Conformist| C G[Inventory Context] -->|Separate Ways| A9. Practical Example: Order and Payment Aggregates
Java:
// Domain: Orders (Aggregate Root) — immutable. All fields final, ctor private,// transitions return new instances. The total invariant (sum of subtotals)// is enforced in the constructor; addItem rebuilds the aggregate consistently.public final class Order { private final UUID id; private final List<OrderItem> items; private final Money total; private final long version;
private Order(UUID id, List<OrderItem> items, Money total, long version) { Money recomputed = items.stream() .map(OrderItem::getSubtotal) .reduce(Money.ZERO, Money::add); if (!total.equals(recomputed)) { throw new IllegalArgumentException("Order.total must equal sum of item subtotals"); } if (version < 0) throw new IllegalArgumentException("version must be non-negative"); this.id = id; this.items = List.copyOf(items); this.total = total; this.version = version; }
public static Order create(UUID id) { return new Order(id, List.of(), Money.ZERO, 0L); }
public static Order rehydrate(UUID id, List<OrderItem> items, Money total, long version) { return new Order(id, items, total, version); }
public Order addItem(OrderItem item) { List<OrderItem> updated = new ArrayList<>(items.size() + 1); updated.addAll(items); updated.add(item); Money newTotal = updated.stream() .map(OrderItem::getSubtotal) .reduce(Money.ZERO, Money::add); return new Order(id, updated, newTotal, version + 1); }
public UUID getId() { return id; } public List<OrderItem> getItems(){ return items; } // already unmodifiable public Money getTotal() { return total; } public long getVersion() { return version; }}
// Domain: Payments (Aggregate Root) — immutable, intention-revealing transitions.public final class Payment { private final UUID id; private final UUID orderId; private final Money amount; private final PaymentStatus status; private final long version;
private Payment(UUID id, UUID orderId, Money amount, PaymentStatus status, long version) { this.id = id; this.orderId = orderId; this.amount = amount; this.status = status; this.version = version; }
public static Payment request(UUID id, UUID orderId, Money amount) { return new Payment(id, orderId, amount, PaymentStatus.PENDING, 0L); }
public static Payment rehydrate(UUID id, UUID orderId, Money amount, PaymentStatus status, long version) { return new Payment(id, orderId, amount, status, version); }
public Payment approve() { if (this.status != PaymentStatus.PENDING) { throw new IllegalStateException("Only pending payments can be approved"); } return new Payment(id, orderId, amount, PaymentStatus.APPROVED, version + 1); }}Kotlin:
// Domain: Orders (Aggregate Root) — regular class, NOT data class.// `total` is a derived invariant: total == sum(items.subtotal). A data class would// auto-expose copy(items=..., total=...) that lets callers break the invariant.class Order private constructor( val id: UUID, val items: List<OrderItem>, val total: Money, val version: Long,) { init { val recomputed = items.map { it.subtotal }.fold(Money.ZERO, Money::add) require(total == recomputed) { "Order.total must equal sum of item subtotals" } require(version >= 0) { "version must be non-negative" } }
fun addItem(item: OrderItem): Order { val updated = items + item val newTotal = updated.map { it.subtotal }.fold(Money.ZERO, Money::add) return Order(id, updated, newTotal, version + 1) }
companion object { fun create(id: UUID): Order = Order(id, emptyList(), Money.ZERO, version = 0) fun rehydrate(id: UUID, items: List<OrderItem>, total: Money, version: Long): Order = Order(id, items, total, version) }}
// Domain: Payments (Aggregate Root) — same reasoning. `approve()` enforces// PENDING → APPROVED; data class.copy(status = APPROVED) would skip the check.class Payment private constructor( val id: UUID, val orderId: UUID, val amount: Money, val status: PaymentStatus, val version: Long,) { fun approve(): Payment { check(status == PaymentStatus.PENDING) { "Only pending payments can be approved" } return Payment(id, orderId, amount, PaymentStatus.APPROVED, version + 1) }
companion object { fun request(id: UUID, orderId: UUID, amount: Money): Payment = Payment(id, orderId, amount, PaymentStatus.PENDING, version = 0) fun rehydrate(id: UUID, orderId: UUID, amount: Money, status: PaymentStatus, version: Long): Payment = Payment(id, orderId, amount, status, version) }}10. DDD in This Project
This project applies DDD principles concretely. Here are real examples from the codebase:
Organization as Aggregate Root
Rule (DDD + clean-code). An aggregate root OWNS its invariants and state transitions. NEVER expose a setter. NEVER use a Kotlin
data classfor an aggregate root —copy(...)lets callers bypass the guards and rebuild illegal states. Use a regularclasswith aprivateconstructor, acompanion objectfactory for creation, and one method per state transition. The constructor /initblock enforces structural invariants; each transition method enforces dynamic invariants (legality ofstate → state').
Kotlin (canonical shape):
class Organization private constructor( val id: UUID, val name: OrganizationName, val contactName: String, val contactLastName: String, val contactEmail: Email, val contactPhone: String, val status: OrganizationStatus, val logoUrl: String?, val websiteUrl: String?, val createdAt: Instant, val createdBy: String, val updatedAt: Instant, val updatedBy: String, val version: Long,) { init { require(contactName.isNotBlank()) { "contactName required" } require(contactLastName.isNotBlank()) { "contactLastName required" } require(version >= 0) { "version must be non-negative" } }
fun transitionTo(target: OrganizationStatus, by: String, clock: Clock): Organization { require(status.canTransitionTo(target)) { "Illegal Organization transition: $status → $target" } return Organization( id, name, contactName, contactLastName, contactEmail, contactPhone, target, logoUrl, websiteUrl, createdAt, createdBy, clock.instant(), by, version + 1, ) }
fun rename(newName: OrganizationName, by: String, clock: Clock): Organization = Organization( id, newName, contactName, contactLastName, contactEmail, contactPhone, status, logoUrl, websiteUrl, createdAt, createdBy, clock.instant(), by, version + 1, )
fun isActive(): Boolean = status == OrganizationStatus.ACTIVE
companion object { fun create( id: UUID, name: OrganizationName, contactName: String, contactLastName: String, contactEmail: Email, contactPhone: String, createdBy: String, clock: Clock, ): Organization { val now = clock.instant() return Organization( id, name, contactName, contactLastName, contactEmail, contactPhone, OrganizationStatus.PENDING_APPROVAL, logoUrl = null, websiteUrl = null, createdAt = now, createdBy = createdBy, updatedAt = now, updatedBy = createdBy, version = 0, ) } }}Java equivalent:
public final class Organization { private final UUID id; private final OrganizationName name; private final String contactName; private final String contactLastName; private final Email contactEmail; private final String contactPhone; private final OrganizationStatus status; private final String logoUrl; private final String websiteUrl; private final Instant createdAt; private final String createdBy; private final Instant updatedAt; private final String updatedBy; private final long version;
private Organization(/* all fields */) { /* assign + validate */ }
public static Organization create(UUID id, OrganizationName name, String contactName, String contactLastName, Email contactEmail, String contactPhone, String createdBy, Clock clock) { Instant now = clock.instant(); return new Organization(id, name, contactName, contactLastName, contactEmail, contactPhone, OrganizationStatus.PENDING_APPROVAL, null, null, now, createdBy, now, createdBy, 0L); }
public Organization transitionTo(OrganizationStatus target, String by, Clock clock) { if (!status.canTransitionTo(target)) { throw new IllegalStateException( "Illegal Organization transition: " + status + " → " + target); } return new Organization(id, name, contactName, contactLastName, contactEmail, contactPhone, target, logoUrl, websiteUrl, createdAt, createdBy, clock.instant(), by, version + 1); }
public boolean isActive() { return status == OrganizationStatus.ACTIVE; }
// Getters only — no setters.}Why not
data classhere.data classauto-generatescopy(...), which would let any caller doorg.copy(status = OrganizationStatus.ACTIVE)skippingtransitionTo’scanTransitionToguard. The aggregate root loses control of its own state machine, and the “behavior on the entity” rule is enforced only by convention. A regular class withprivateconstructor closes that hole at the compiler.
Value Objects referenced above (
OrganizationName,domain/.../vo/. Unlike aggregate roots, value objects MAY be Kotlindata class/ Javarecord— they have no identity and no state machine, so structural copy is safe. They validate in their constructor (e.g.
OrganizationStatus as Value Object (Enum)
The enum OWNS its transition matrix.
Organization.transitionTodelegates legality tostatus.canTransitionTo(target), so the state machine lives in one place and the aggregate never hardcodes awhen/switchover states.PENDING_APPROVALis the creation state (seeOrganization.create); it is terminal-inbound — nothing transitions back to it.
enum class OrganizationStatus { PENDING_APPROVAL, ACTIVE, INACTIVE, SUSPENDED;
fun canTransitionTo(target: OrganizationStatus): Boolean = target in allowedTransitions
private val allowedTransitions: Set<OrganizationStatus> get() = when (this) { PENDING_APPROVAL -> setOf(ACTIVE, INACTIVE) ACTIVE -> setOf(INACTIVE, SUSPENDED) INACTIVE -> setOf(ACTIVE) SUSPENDED -> setOf(ACTIVE, INACTIVE) }}public enum OrganizationStatus { PENDING_APPROVAL, ACTIVE, INACTIVE, SUSPENDED;
private static final Map<OrganizationStatus, Set<OrganizationStatus>> ALLOWED = Map.of( PENDING_APPROVAL, Set.of(ACTIVE, INACTIVE), ACTIVE, Set.of(INACTIVE, SUSPENDED), INACTIVE, Set.of(ACTIVE), SUSPENDED, Set.of(ACTIVE, INACTIVE) );
public boolean canTransitionTo(OrganizationStatus target) { return ALLOWED.get(this).contains(target); }}Command Pattern
data class CreateOrganizationCommand( val name: String, val contactName: String, val contactLastName: String, val contactEmail: String, val contactPhone: String,)public record CreateOrganizationCommand( String name, String contactName, String contactLastName, String contactEmail, String contactPhone) {}Repository Port (Abstraction)
interface OrganizationRepositoryPort { fun save(organization: Organization): Organization fun update(organization: Organization): Organization fun findById(id: UUID): Organization?}public interface OrganizationRepositoryPort { Organization save(Organization organization); Organization update(Organization organization); Optional<Organization> findById(UUID id);}Domain Events (produced by the aggregate, dispatched via Outbox)
A state transition is also a business fact. The aggregate is the only thing that knows a
transition was legal, so it is the only thing entitled to declare the event. Keep events as
immutable value objects in the domain layer (no framework, no broker types). The use case
writes the business row AND the outbox row inside ONE local transaction — it NEVER calls a
broker directly (outbox-first; see messaging.md).
// domain — immutable fact, framework-freesealed interface OrganizationEvent { val organizationId: UUID val occurredAt: Instant}data class OrganizationCreated( override val organizationId: UUID, val name: String, override val occurredAt: Instant,) : OrganizationEvent
// application — thin orchestration: create → save → outbox, all in one TXclass CreateOrganizationUseCase( private val organizations: OrganizationRepositoryPort, private val outbox: OutboxRepositoryPort, private val events: OutboxEventFactory, private val transaction: TransactionPort, private val clock: Clock,) : CreateOrganizationPort { override fun execute(cmd: CreateOrganizationCommand): Organization = transaction.run { val org = Organization.create(/* mapped from cmd */, clock = clock) val saved = organizations.save(org) outbox.save(events.create(OrganizationCreated(saved.id, saved.name.value, clock.instant()))) saved }}Why the event is built in the use case, not inside
Organization.create: the domain declares the fact type (OrganizationCreated); the application decides when/whether to persist it to the outbox. This keeps the domain free ofOutboxRepositoryPortand matches the ArchUnit ruleuseCasesShouldNotInjectMessagePublisherPort(publish is the dispatcher’s job, not the use case’s). Seemessaging.mdandhexagonal-architecture.md §Messaging.
11. Recommendations
- Model with domain experts. Use Event Storming to discover domain events and aggregates.
- Keep the model context limited. Each bounded context should be small and focused.
- Avoid forcing DDD on simple CRUD domains. Use DDD where there is genuine business complexity.
- Design integrations with explicit contracts. Use ACL to isolate external models.
- Apply Ubiquitous Language consistently in code, documentation, and conversations.
- Domain entities MUST be immutable or use controlled mutation (return new instances).
- Domain entities MUST NOT depend on frameworks, databases, or infrastructure.
11.1 Anti-pattern: Anemic Domain Model
Fowler — “Anemic Domain Model”: an object model where domain objects contain little or no behavior; logic lives in transaction-scripts (services / use cases) that fetch the entity, mutate its fields, and save it. It is a procedural design wearing OO clothes.
How to spot it
- The “entity” is a
data class/ record / POJO with only fields + accessors. - Status changes happen via
setStatus(...)orentity.copy(status = NEW)in a service. - Business rules (“ACTIVE cannot move to PENDING_APPROVAL”) live in the use case, not the entity.
- Multiple unrelated services know how to mutate the same entity — no single owner of its invariants.
- Tests for “the rule” import the service, not the entity.
Why it’s wrong
- The aggregate has no way to refuse an illegal state — every new caller is a fresh chance to skip the rule.
- Invariants drift: the rule lives in N services, gets out of sync as features land.
- Use cases bloat into transaction scripts; nothing in the domain layer captures the model.
- Refactoring becomes risky because the “model” is just shape — there’s no behavior to preserve.
How to fix
- Move every state-changing operation onto the entity, named in domain language and carrying the same audit/time context as the canonical shape (
activate(by, clock),transitionTo(target, by, clock),cancel(reason, by, clock)). - Replace public setters with intention-revealing methods that return a new instance.
- In Kotlin: convert aggregate roots from
data class→ regularclasswithprivateconstructor +companion objectfactory. Reservedata classfor DTOs, payloads, and pure value objects. - In Java: make the constructor
private, exposestatic create(...)factories, drop setters. - Enforce structural invariants in the constructor /
initblock; enforce dynamic invariants (legality of transitions) inside each transition method. - Push value-object validation (
Email,OrganizationName,Money) into dedicated types — primitive obsession is anemia’s pre-stage. - Run ArchUnit / code review: any class under
..domain..withpublic set*is suspect.
Quick comparison
// ❌ Anemic — service holds the rule, entity is a data bagdata class Organization(val id: UUID, val name: String, val status: OrganizationStatus)
class OrganizationService(private val repo: OrganizationRepositoryPort) { fun activate(id: UUID) { val org = repo.findById(id) ?: throw NotFound() if (org.status != OrganizationStatus.INACTIVE) throw IllegalState() repo.save(org.copy(status = OrganizationStatus.ACTIVE)) // rule lives here, fragile }}
// ✅ Rich — entity owns the rule; the use case orchestrates onlyclass Organization private constructor(/* ... */) { fun activate(by: String, clock: Clock): Organization { require(status == OrganizationStatus.INACTIVE) { "only INACTIVE can activate" } return Organization(/* ..., status = ACTIVE, version + 1 */) } companion object { fun create(/* ... */): Organization = /* ... */ }}
class ActivateOrganizationUseCase(/* ... */) : ActivateOrganizationPort { override fun execute(cmd: ActivateOrganizationCommand): Organization = transaction.run { val org = organizations.findById(cmd.id) ?: throw OrganizationNotFoundException(cmd.id) val activated = organizations.save(org.activate(by = cmd.actor, clock = clock)) outbox.save(events.create(activated)) // + outbox.save(...) in the same TX activated }}The use case becomes a thin orchestration shell: load → call behavior on entity → save (+ outbox). All business rules sit in Organization, where they belong.
12. References
- Eric Evans – Domain-Driven Design: Tackling Complexity in the Heart of Software
- Vaughn Vernon – Implementing Domain-Driven Design
- Alberto Brandolini – Introducing Event Storming