Saltar al contenido
Mis Guidelines
Guías
En esta guía (24 elementos)

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):

  1. domain – pure logic, no framework deps, immutable. Entities, value objects, core business rules.
  2. application – use cases + ports, depends on domain only, no annotations. Orchestrates use cases and transactions. Contains NO business logic.
  3. 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”.
  4. 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| A

9. 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 class for an aggregate root — copy(...) lets callers bypass the guards and rebuild illegal states. Use a regular class with a private constructor, a companion object factory for creation, and one method per state transition. The constructor / init block enforces structural invariants; each transition method enforces dynamic invariants (legality of state → state').

Kotlin (canonical shape):

domain/src/main/kotlin/com/example/platform/domain/Organization.kt
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 class here. data class auto-generates copy(...), which would let any caller do org.copy(status = OrganizationStatus.ACTIVE) skipping transitionTo’s canTransitionTo guard. 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 with private constructor closes that hole at the compiler.

Value Objects referenced above (OrganizationName, Email) are immutable self-validating types in domain/.../vo/. Unlike aggregate roots, value objects MAY be Kotlin data class / Java record — they have no identity and no state machine, so structural copy is safe. They validate in their constructor (e.g. Email rejects malformed input), pushing primitive-obsession validation out of the aggregate.

OrganizationStatus as Value Object (Enum)

The enum OWNS its transition matrix. Organization.transitionTo delegates legality to status.canTransitionTo(target), so the state machine lives in one place and the aggregate never hardcodes a when/switch over states. PENDING_APPROVAL is the creation state (see Organization.create); it is terminal-inbound — nothing transitions back to it.

domain/src/main/kotlin/com/example/platform/domain/enums/OrganizationStatus.kt
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

application/src/main/kotlin/.../command/CreateOrganizationCommand.kt
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)

application/src/main/kotlin/.../port/outbound/repository/OrganizationRepositoryPort.kt
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-free
sealed 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 TX
class 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 of OutboxRepositoryPort and matches the ArchUnit rule useCasesShouldNotInjectMessagePublisherPort (publish is the dispatcher’s job, not the use case’s). See messaging.md and hexagonal-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(...) or entity.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

  1. 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)).
  2. Replace public setters with intention-revealing methods that return a new instance.
  3. In Kotlin: convert aggregate roots from data class → regular class with private constructor + companion object factory. Reserve data class for DTOs, payloads, and pure value objects.
  4. In Java: make the constructor private, expose static create(...) factories, drop setters.
  5. Enforce structural invariants in the constructor / init block; enforce dynamic invariants (legality of transitions) inside each transition method.
  6. Push value-object validation (Email, OrganizationName, Money) into dedicated types — primitive obsession is anemia’s pre-stage.
  7. Run ArchUnit / code review: any class under ..domain.. with public set* is suspect.

Quick comparison

// ❌ Anemic — service holds the rule, entity is a data bag
data 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 only
class 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