Capítulo 3: Dominio y Entidades
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:
- Entidades: Objetos con identidad única
- Value Objects: Objetos sin identidad, definidos por sus atributos
- Agregados: Grupos de entidades tratados como una unidad
- Reglas de negocio: Lógica que protege la integridad del modelo
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:
- Identidad única: Se distingue por su ID, no por sus atributos
- Ciclo de vida: Puede cambiar en el tiempo
- Continuidad: Sigue siendo la misma entidad aunque cambien sus atributos
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
- Constructor privado: Usa factory methods para crear instancias
- ID readonly: La identidad nunca cambia
- No setters públicos: Solo métodos de negocio con validaciones
- Validación en métodos: Protege invariantes del negocio
- 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:
- Inmutable: No cambia después de crearse
- Sin identidad: Dos VOs con mismos atributos son iguales
- Intercambiable: Puedes reemplazar uno por otro con mismos valores
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ística | Entidad | Value Object |
|---|---|---|
| Identidad | Única (ID) | No tiene |
| Igualdad | Por ID | Por atributos |
| Mutabilidad | Mutable | Inmutable |
| Ciclo de vida | Tiene | No tiene |
| Ejemplo | User, Order | Email, 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
- Validación centralizada: Una sola vez en el constructor
- Reutilización: Email se usa en User, Notification, etc.
- Expresividad:
Moneyes más claro quenumber - Seguridad de tipos: No puedes pasar un string donde va un Email
- Sin primitives obsession: Evitas
string,numberen todo
4. Agregados
Definición
Un agregado es un grupo de entidades y value objects tratados como una unidad:
- Raíz del agregado: Entidad principal que controla el acceso
- Consistencia: Mantiene invariantes del grupo completo
- Transacción: Se guarda y carga como una unidad
- Frontera: Solo se accede al agregado por su raíz
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
- Una raíz: Solo una entidad es la raíz del agregado
- Acceso controlado: Modificaciones solo a través de la raíz
- Consistencia: La raíz mantiene invariantes del agregado completo
- Frontera transaccional: Se guarda/carga como una unidad
- 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:
- Dominio: El corazón de la hexagonal, solo lógica de negocio pura
- Entidades: Objetos con identidad única y ciclo de vida
- Value Objects: Objetos inmutables sin identidad, definidos por atributos
- Agregados: Grupos de entidades con una raíz que controla acceso
- Invariantes: Reglas que siempre deben cumplirse
Principios clave:
- ✅ Constructor privado + factory methods
- ✅ Sin dependencias externas
- ✅ Validación en métodos de negocio
- ✅ Value Objects para conceptos del dominio
- ✅ Agregados con raíz que protege invariantes
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 |
|---|---|---|
| Domain | Dominio | Núcleo de la aplicación con lógica de negocio pura |
| Entity | Entidad | Objeto con identidad única y ciclo de vida |
| Value Object | Objeto de Valor | Objeto inmutable sin identidad |
| Aggregate | Agregado | Grupo de entidades tratadas como unidad |
| Aggregate Root | Raíz del Agregado | Entidad principal que controla acceso al agregado |
| Invariant | Invariante | Regla que siempre debe cumplirse |
| Factory Method | Método Fábrica | Método estático para crear instancias |
| Business Logic | Lógica de Negocio | Reglas y comportamientos del dominio |