← Volver al listado de tecnologías

Capítulo 3: Dominio y Entidades

Por: Alfred Pennyworth
arquitectura-hexagonaltypescriptddddominioentidades

Capítulo 3: Dominio y Entidades

Introducción

El dominio es el corazón de la arquitectura hexagonal. Es donde vive la lógica de negocio pura, independiente de bases de datos, frameworks o APIs. En este capítulo aprenderás a modelar entidades, value objects y agregados.

1. ¿Qué es el Dominio?

Definición

El dominio es el área de conocimiento y actividad del negocio que estamos modelando en software. Contiene:

Características del Dominio en Hexagonal

// ✅ Dominio puro
class Order {
  // Sin dependencias externas
  // Sin anotaciones de frameworks
  // Solo TypeScript puro
  private constructor(
    private readonly id: string,
    private items: OrderItem[]
  ) {}

  static create(id: string): Order {
    return new Order(id, []);
  }

  addItem(item: OrderItem): void {
    // Lógica de negocio pura
    if (this.items.length >= 10) {
      throw new Error('Máximo 10 items por orden');
    }
    this.items.push(item);
  }
}

Regla de oro: Si importas algo que no sea TypeScript puro, no es dominio.

2. Entidades

Definición

Una entidad es un objeto del dominio con:

Ejemplo: Dos Usuarios Distintos

const user1 = new User('123', '[email protected]');
const user2 = new User('456', '[email protected]');

// Son diferentes entidades aunque tengan el mismo email
user1.id !== user2.id // true

Anatomía de una Entidad

class User {
  // 1. Identidad (readonly)
  private constructor(
    private readonly id: string,
    private email: string,
    private name: string,
    private createdAt: Date
  ) {}

  // 2. Factory method (constructor privado)
  static create(email: string, name: string): User {
    return new User(
      crypto.randomUUID(),
      email,
      name,
      new Date()
    );
  }

  // 3. Factory desde persistencia
  static fromPersistence(data: {
    id: string;
    email: string;
    name: string;
    createdAt: Date;
  }): User {
    return new User(data.id, data.email, data.name, data.createdAt);
  }

  // 4. Getters (sin setters)
  get userId(): string {
    return this.id;
  }

  get userEmail(): string {
    return this.email;
  }

  // 5. Métodos de negocio (no setters)
  changeName(newName: string): void {
    if (!newName || newName.trim().length === 0) {
      throw new Error('Nombre no puede estar vacío');
    }
    this.name = newName;
  }

  changeEmail(newEmail: string): void {
    if (!newEmail.includes('@')) {
      throw new Error('Email inválido');
    }
    this.email = newEmail;
  }
}

Principios de Entidades

  1. Constructor privado: Usa factory methods para crear instancias
  2. ID readonly: La identidad nunca cambia
  3. No setters públicos: Solo métodos de negocio con validaciones
  4. Validación en métodos: Protege invariantes del negocio
  5. Sin lógica de persistencia: No sabe de bases de datos

3. Value Objects

Definición

Un Value Object es un objeto sin identidad, definido completamente por sus atributos:

Ejemplo: Email como Value Object

class Email {
  private constructor(private readonly value: string) {}

  static create(email: string): Email {
    if (!email || !email.includes('@')) {
      throw new Error('Email inválido');
    }
    const normalized = email.toLowerCase().trim();
    return new Email(normalized);
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

// Uso
const email1 = Email.create('[email protected]');
const email2 = Email.create('[email protected]');

email1.equals(email2); // true - son el mismo valor

Entidad vs Value Object

CaracterísticaEntidadValue Object
IdentidadÚnica (ID)No tiene
IgualdadPor IDPor atributos
MutabilidadMutableInmutable
Ciclo de vidaTieneNo tiene
EjemploUser, OrderEmail, Money, Address

Más Value Objects

// Money - Dinero con moneda
class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {}

  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new Error('Monto no puede ser negativo');
    if (!['USD', 'EUR', 'MXN'].includes(currency)) {
      throw new Error('Moneda no soportada');
    }
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('No se pueden sumar monedas diferentes');
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

// DateRange - Rango de fechas
class DateRange {
  private constructor(
    private readonly start: Date,
    private readonly end: Date
  ) {}

  static create(start: Date, end: Date): DateRange {
    if (start > end) {
      throw new Error('Fecha inicio debe ser antes que fecha fin');
    }
    return new DateRange(start, end);
  }

  contains(date: Date): boolean {
    return date >= this.start && date <= this.end;
  }

  overlaps(other: DateRange): boolean {
    return this.start <= other.end && this.end >= other.start;
  }
}

Ventajas de Value Objects

  1. Validación centralizada: Una sola vez en el constructor
  2. Reutilización: Email se usa en User, Notification, etc.
  3. Expresividad: Money es más claro que number
  4. Seguridad de tipos: No puedes pasar un string donde va un Email
  5. Sin primitives obsession: Evitas string, number en todo

4. Agregados

Definición

Un agregado es un grupo de entidades y value objects tratados como una unidad:

Ejemplo: Order (Raíz) con OrderItems

class OrderItem {
  constructor(
    readonly productId: string,
    readonly quantity: number,
    readonly price: Money
  ) {
    if (quantity <= 0) throw new Error('Cantidad debe ser positiva');
  }

  total(): Money {
    return Money.create(
      this.price.amount * this.quantity,
      this.price.currency
    );
  }
}

class Order {
  private constructor(
    private readonly id: string,
    private items: OrderItem[],
    private status: 'draft' | 'confirmed' | 'cancelled'
  ) {}

  static create(): Order {
    return new Order(crypto.randomUUID(), [], 'draft');
  }

  // ✅ Acceso controlado: solo la raíz puede agregar items
  addItem(productId: string, quantity: number, price: Money): void {
    if (this.status !== 'draft') {
      throw new Error('Solo se pueden agregar items a orders en borrador');
    }

    // Invariante: máximo 10 items
    if (this.items.length >= 10) {
      throw new Error('Máximo 10 items por orden');
    }

    const item = new OrderItem(productId, quantity, price);
    this.items.push(item);
  }

  // ✅ La raíz protege el estado interno
  confirm(): void {
    if (this.items.length === 0) {
      throw new Error('No se puede confirmar orden vacía');
    }
    if (this.status !== 'draft') {
      throw new Error('Solo se pueden confirmar orders en borrador');
    }
    this.status = 'confirmed';
  }

  // ✅ Cálculos que involucran todo el agregado
  total(): Money {
    if (this.items.length === 0) {
      return Money.create(0, 'USD');
    }
    return this.items.reduce(
      (sum, item) => sum.add(item.total()),
      Money.create(0, this.items[0].price.currency)
    );
  }

  // ❌ NO expongas colecciones directamente
  // getItems(): OrderItem[] { return this.items; }

  // ✅ Expón datos inmutables
  getItemsCount(): number {
    return this.items.length;
  }
}

Reglas de Agregados

  1. Una raíz: Solo una entidad es la raíz del agregado
  2. Acceso controlado: Modificaciones solo a través de la raíz
  3. Consistencia: La raíz mantiene invariantes del agregado completo
  4. Frontera transaccional: Se guarda/carga como una unidad
  5. Referencias: Otros agregados se referencian solo por ID

Ejemplo: Referencias Entre Agregados

// ✅ Correcto: Referencia por ID
class Order {
  constructor(
    private readonly id: string,
    private readonly userId: string, // Solo el ID
    private items: OrderItem[]
  ) {}
}

// ❌ Incorrecto: Referencia directa
class Order {
  constructor(
    private readonly id: string,
    private readonly user: User, // ¡NO! User es otro agregado
    private items: OrderItem[]
  ) {}
}

5. Invariantes de Negocio

Definición

Los invariantes son reglas que siempre deben cumplirse para mantener la integridad del modelo.

Tipos de Invariantes

class BankAccount {
  private constructor(
    private readonly id: string,
    private balance: Money,
    private readonly overdraftLimit: Money
  ) {}

  // Invariante 1: El balance nunca puede ser menor al límite
  withdraw(amount: Money): void {
    const newBalance = this.balance.subtract(amount);

    if (newBalance.amount < -this.overdraftLimit.amount) {
      throw new Error('Sobregiro excedido');
    }

    this.balance = newBalance;
  }

  // Invariante 2: Solo se pueden hacer depósitos positivos
  deposit(amount: Money): void {
    if (amount.amount <= 0) {
      throw new Error('Monto debe ser positivo');
    }
    this.balance = this.balance.add(amount);
  }
}

class ShoppingCart {
  private constructor(
    private readonly id: string,
    private items: CartItem[]
  ) {}

  // Invariante: Máximo 50 items en el carrito
  addItem(item: CartItem): void {
    if (this.items.length >= 50) {
      throw new Error('Carrito lleno');
    }
    this.items.push(item);
  }

  // Invariante: No puede haber items duplicados
  private hasDuplicate(productId: string): boolean {
    return this.items.some(item => item.productId === productId);
  }
}

Protegiendo Invariantes

class User {
  private constructor(
    private readonly id: string,
    private email: Email,
    private status: 'active' | 'suspended' | 'deleted'
  ) {}

  // Invariante: Usuario suspendido no puede activarse directamente
  activate(): void {
    if (this.status === 'deleted') {
      throw new Error('Usuario eliminado no puede activarse');
    }
    if (this.status === 'suspended') {
      throw new Error('Usuario suspendido debe ser reactivado por admin');
    }
    this.status = 'active';
  }

  // Método especial para admin
  reactivateFromSuspension(): void {
    if (this.status !== 'suspended') {
      throw new Error('Solo usuarios suspendidos pueden reactivarse');
    }
    this.status = 'active';
  }
}

6. Ejemplo Completo: E-commerce

// ========== VALUE OBJECTS ==========
class ProductId {
  private constructor(readonly value: string) {}
  static create(id: string): ProductId {
    if (!id) throw new Error('ProductId requerido');
    return new ProductId(id);
  }
}

class Money {
  private constructor(
    readonly amount: number,
    readonly currency: string
  ) {}

  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new Error('Monto negativo');
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('Monedas diferentes');
    }
    return Money.create(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return Money.create(this.amount * factor, this.currency);
  }
}

// ========== ENTIDADES ==========
class OrderItem {
  constructor(
    readonly productId: ProductId,
    readonly quantity: number,
    readonly unitPrice: Money
  ) {
    if (quantity <= 0) throw new Error('Cantidad inválida');
  }

  total(): Money {
    return this.unitPrice.multiply(this.quantity);
  }
}

// ========== AGREGADO ==========
class Order {
  private constructor(
    private readonly id: string,
    private readonly customerId: string,
    private items: OrderItem[],
    private status: 'draft' | 'placed' | 'cancelled'
  ) {}

  static create(customerId: string): Order {
    return new Order(crypto.randomUUID(), customerId, [], 'draft');
  }

  addItem(productId: ProductId, quantity: number, price: Money): void {
    // Invariantes
    if (this.status !== 'draft') {
      throw new Error('Solo orders en borrador pueden modificarse');
    }
    if (this.items.length >= 20) {
      throw new Error('Máximo 20 items');
    }

    const item = new OrderItem(productId, quantity, price);
    this.items.push(item);
  }

  place(): void {
    if (this.items.length === 0) {
      throw new Error('Order vacía no puede confirmarse');
    }
    if (this.status !== 'draft') {
      throw new Error('Order ya fue confirmada');
    }
    this.status = 'placed';
  }

  cancel(): void {
    if (this.status === 'cancelled') {
      throw new Error('Order ya cancelada');
    }
    this.status = 'cancelled';
  }

  total(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.total()),
      Money.create(0, 'USD')
    );
  }

  // Getters inmutables
  get orderId(): string { return this.id; }
  get orderStatus(): string { return this.status; }
  get itemCount(): number { return this.items.length; }
}

7. Conclusión

En este capítulo aprendiste:

  1. Dominio: El corazón de la hexagonal, solo lógica de negocio pura
  2. Entidades: Objetos con identidad única y ciclo de vida
  3. Value Objects: Objetos inmutables sin identidad, definidos por atributos
  4. Agregados: Grupos de entidades con una raíz que controla acceso
  5. Invariantes: Reglas que siempre deben cumplirse

Principios clave:

En el próximo capítulo veremos cómo exponer el dominio a través de puertos primarios.

Glosario del Capítulo

Término (Inglés)Término (Español)Definición
DomainDominioNúcleo de la aplicación con lógica de negocio pura
EntityEntidadObjeto con identidad única y ciclo de vida
Value ObjectObjeto de ValorObjeto inmutable sin identidad
AggregateAgregadoGrupo de entidades tratadas como unidad
Aggregate RootRaíz del AgregadoEntidad principal que controla acceso al agregado
InvariantInvarianteRegla que siempre debe cumplirse
Factory MethodMétodo FábricaMétodo estático para crear instancias
Business LogicLógica de NegocioReglas y comportamientos del dominio

Referencias