Saltar al contenido

Domain-Driven Design: bloques de construcción esenciales

Entidades, value objects, agregados y bounded contexts: los patrones DDD que ordenan la lógica compleja sin complicar los sistemas simples.

3 min de lectura
Diagrama de modelo de dominio que muestra bounded contexts y límites de agregados

Domain-Driven Design es un conjunto de patrones para organizar la lógica de negocio compleja. No se trata de usar frameworks o estructuras de carpetas específicas: se trata de modelar el software en torno al dominio real del negocio, usando un lenguaje compartido entre desarrolladores y expertos de dominio. Los patrones tácticos (entidades, value objects, agregados) te dan herramientas concretas para implementar ese modelo.

Entities vs. Value Objects

Una entity tiene una identidad única que persiste en el tiempo. Un value object se define enteramente por sus atributos: dos value objects con los mismos atributos son intercambiables.

tstypescript
// Entity: identified by its ID, attributes can change
class Order {
  constructor(
    readonly id: string,
    private items: OrderItem[],
    private status: OrderStatus
  ) {}
 
  addItem(item: OrderItem): void {
    if (this.status !== "draft") {
      throw new Error("Cannot modify a non-draft order");
    }
    this.items.push(item);
  }
 
  // Two orders with the same items are still different orders
  equals(other: Order): boolean {
    return this.id === other.id;
  }
}
 
// Value Object: defined by its attributes, immutable
class Money {
  constructor(
    readonly amount: number,
    readonly currency: string
  ) {
    if (amount < 0) throw new Error("Amount cannot be negative");
    if (!currency.match(/^[A-Z]{3}$/)) throw new Error("Invalid currency code");
  }
 
  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Cannot add different currencies");
    }
    return new Money(this.amount + other.amount, this.currency);
  }
 
  // Two Money objects with the same amount and currency are equal
  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

Aggregates

Un aggregate es un grupo de entities y value objects con una única entity raíz que controla todo el acceso. El código externo solo referencia al aggregate a través de su raíz; esto hace cumplir las reglas de consistencia.

tstypescript
// ❌ Direct access to internals — no consistency guarantees
const order = await orderRepo.findById(orderId);
const item = order.items[0];
item.quantity = 100;  // Modified directly, bypassing business rules
await orderItemRepo.save(item);
 
// ✅ Access through aggregate root — invariants are enforced
class Order {
  private items: OrderItem[] = [];
  private status: OrderStatus = "draft";
 
  updateItemQuantity(itemId: string, newQuantity: number): void {
    if (this.status !== "draft") {
      throw new Error("Cannot modify a submitted order");
    }
 
    const item = this.items.find(i => i.id === itemId);
    if (!item) throw new Error(`Item ${itemId} not found`);
 
    if (newQuantity <= 0) {
      this.items = this.items.filter(i => i.id !== itemId);
      return;
    }
 
    if (newQuantity > 100) {
      throw new Error("Maximum quantity per item is 100");
    }
 
    item.updateQuantity(newQuantity);
  }
 
  get totalAmount(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.lineTotal),
      new Money(0, "USD")
    );
  }
}
 
// External code uses the aggregate root
const order = await orderRepo.findById(orderId);
order.updateItemQuantity(itemId, 5);
await orderRepo.save(order); // Saves the entire aggregate

Bounded Contexts

Un bounded context define el límite dentro del cual aplica un modelo. El mismo concepto del mundo real (por ejemplo, "User") puede tener representaciones diferentes en distintos contextos.

tstypescript
// ❌ Shared "User" model across the entire system
interface User {
  id: string;
  name: string;
  email: string;
  shippingAddress: Address;     // Only relevant in orders
  creditScore: number;          // Only relevant in payments
  loginAttempts: number;        // Only relevant in auth
  preferredCategories: string[]; // Only relevant in recommendations
}
 
// ✅ Each bounded context has its own model
 
// Auth context — cares about credentials
interface AuthUser {
  id: string;
  email: string;
  passwordHash: string;
  loginAttempts: number;
  lockedUntil: Date | null;
}
 
// Order context — cares about shipping
interface OrderCustomer {
  id: string;
  name: string;
  shippingAddress: Address;
  loyaltyTier: "standard" | "premium";
}
 
// Payment context — cares about billing
interface PaymentCustomer {
  id: string;
  defaultPaymentMethod: string;
  billingAddress: Address;
  creditLimit: Money;
}

Domain Events

Los domain events capturan cosas importantes que ocurren dentro de un bounded context. Son el mecanismo principal para la comunicación entre contextos.

tstypescript
interface DomainEvent {
  type: string;
  occurredAt: string;
  aggregateId: string;
}
 
class Order {
  private domainEvents: DomainEvent[] = [];
 
  submit(): void {
    if (this.items.length === 0) {
      throw new Error("Cannot submit an empty order");
    }
 
    this.status = "submitted";
    this.submittedAt = new Date();
 
    // Record the domain event
    this.domainEvents.push({
      type: "order.submitted",
      occurredAt: new Date().toISOString(),
      aggregateId: this.id,
    });
  }
 
  pullDomainEvents(): DomainEvent[] {
    const events = [...this.domainEvents];
    this.domainEvents = [];
    return events;
  }
}
 
// Repository publishes events after saving
class OrderRepository {
  async save(order: Order): Promise<void> {
    await this.db.save(order);
 
    const events = order.pullDomainEvents();
    for (const event of events) {
      await this.eventBus.publish(event);
    }
  }
}

Repository Pattern

Los repositories abstraen el mecanismo de persistencia. El modelo de dominio no sabe de bases de datos: trabaja con objetos puros.

tstypescript
// ❌ Domain logic coupled to database queries
async function submitOrder(orderId: string) {
  const row = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]);
  const items = await db.query("SELECT * FROM order_items WHERE order_id = $1", [orderId]);
  // Reconstruct domain object from raw rows...
  // Business logic mixed with SQL
}
 
// ✅ Repository returns domain objects
interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
  findByCustomer(customerId: string, status?: OrderStatus): Promise<Order[]>;
}
 
// Application service uses the repository
class OrderService {
  constructor(private orderRepo: OrderRepository) {}
 
  async submitOrder(orderId: string): Promise<void> {
    const order = await this.orderRepo.findById(orderId);
    if (!order) throw new Error("Order not found");
 
    order.submit(); // Pure domain logic
 
    await this.orderRepo.save(order); // Persistence abstracted
  }
}

Key Takeaways

  1. Entities tienen identidad, value objects tienen atributos — elige la herramienta de modelado correcta para cada concepto
  2. Aggregates hacen cumplir límites de consistencia — todas las modificaciones pasan por la raíz del aggregate
  3. Bounded contexts evitan modelos monolíticos — el mismo concepto significa cosas distintas en distintas partes del sistema
  4. Domain events permiten la comunicación entre contextos — publica eventos después de guardar, deja que otros contextos reaccionen
  5. Repositories ocultan los detalles de persistencia — los objetos de dominio no deberían saber de bases de datos ni ORMs
  6. DDD es para dominios complejos — las aplicaciones CRUD simples no se benefician de este nivel de modelado
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX