Guía Práctica de Diseño Dirigido por el Dominio (DDD) - Aprende diseño táctico con TypeScript

2025.12.02

El Diseño Dirigido por el Dominio (DDD) es una metodología para diseñar e implementar software con lógica de negocio compleja de manera efectiva. En este artículo, explicamos cómo implementar patrones de diseño táctico de DDD en TypeScript.

Conceptos Básicos de DDD

Diseño Estratégico y Diseño Táctico

flowchart TB
    subgraph Strategic["Diseño Estratégico"]
        S1["Lenguaje Ubicuo<br/>(Definición de lenguaje común)"]
        S2["Contexto Delimitado<br/>(División del sistema)"]
        S3["Mapa de Contextos<br/>(Relaciones entre contextos)"]
        S4["Subdominios<br/>(Core/Soporte/Genérico)"]
    end

    subgraph Tactical["Diseño Táctico"]
        T1["Entidad<br/>(Objeto con identificador)"]
        T2["Objeto de Valor<br/>(Valor inmutable)"]
        T3["Agregado<br/>(Límite de consistencia)"]
        T4["Repositorio<br/>(Abstracción de persistencia)"]
        T5["Servicio de Dominio<br/>(Lógica que no pertenece a entidades)"]
        T6["Evento de Dominio<br/>(Notificación de cambios de estado)"]
        T7["Factory<br/>(Creación de objetos complejos)"]
    end

    Strategic --> Tactical

Arquitectura en Capas

flowchart TB
    subgraph P["Capa de Presentación"]
        P1["Controllers, API, UI"]
    end

    subgraph A["Capa de Aplicación"]
        A1["Use Cases, Application Services, DTOs"]
    end

    subgraph D["Capa de Dominio"]
        D1["Entities, Value Objects, Aggregates, Domain Services"]
    end

    subgraph I["Capa de Infraestructura"]
        I1["Repositories, External Services, DB"]
    end

    P --> A --> D --> I

Objetos de Valor

Objeto de Valor Básico

// domain/value-objects/email.ts

export class Email {
  private readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  static create(value: string): Email {
    if (!value || !this.isValid(value)) {
      throw new Error('Invalid email format');
    }
    return new Email(value.toLowerCase().trim());
  }

  private static isValid(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }

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

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

  getDomain(): string {
    return this.value.split('@')[1];
  }

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

Objeto de Valor Compuesto

// domain/value-objects/money.ts

export type Currency = 'JPY' | 'USD' | 'EUR';

export class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: Currency
  ) {
    if (amount < 0) {
      throw new Error('Amount cannot be negative');
    }
  }

  static create(amount: number, currency: Currency): Money {
    return new Money(amount, currency);
  }

  static zero(currency: Currency): Money {
    return new Money(0, currency);
  }

  getAmount(): number {
    return this.amount;
  }

  getCurrency(): Currency {
    return this.currency;
  }

  add(other: Money): Money {
    this.assertSameCurrency(other);
    return new Money(this.amount + other.amount, this.currency);
  }

  subtract(other: Money): Money {
    this.assertSameCurrency(other);
    const result = this.amount - other.amount;
    if (result < 0) {
      throw new Error('Result cannot be negative');
    }
    return new Money(result, this.currency);
  }

  multiply(factor: number): Money {
    if (factor < 0) {
      throw new Error('Factor cannot be negative');
    }
    return new Money(Math.round(this.amount * factor), this.currency);
  }

  isGreaterThan(other: Money): boolean {
    this.assertSameCurrency(other);
    return this.amount > other.amount;
  }

  isLessThan(other: Money): boolean {
    this.assertSameCurrency(other);
    return this.amount < other.amount;
  }

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

  private assertSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new Error('Currency mismatch');
    }
  }

  format(): string {
    const formatter = new Intl.NumberFormat('ja-JP', {
      style: 'currency',
      currency: this.currency,
    });
    return formatter.format(this.amount);
  }
}

Objeto de Valor de Dirección

// domain/value-objects/address.ts

interface AddressProps {
  postalCode: string;
  prefecture: string;
  city: string;
  street: string;
  building?: string;
}

export class Address {
  private constructor(
    private readonly postalCode: string,
    private readonly prefecture: string,
    private readonly city: string,
    private readonly street: string,
    private readonly building?: string
  ) {}

  static create(props: AddressProps): Address {
    this.validatePostalCode(props.postalCode);
    this.validatePrefecture(props.prefecture);

    return new Address(
      props.postalCode,
      props.prefecture,
      props.city,
      props.street,
      props.building
    );
  }

  private static validatePostalCode(postalCode: string): void {
    if (!/^\d{3}-?\d{4}$/.test(postalCode)) {
      throw new Error('Invalid postal code format');
    }
  }

  private static validatePrefecture(prefecture: string): void {
    const prefectures = [
      'Hokkaido', 'Aomori', 'Iwate', /* ... */
    ];
    if (!prefectures.includes(prefecture)) {
      throw new Error('Invalid prefecture');
    }
  }

  getPostalCode(): string {
    return this.postalCode;
  }

  getPrefecture(): string {
    return this.prefecture;
  }

  getFullAddress(): string {
    const parts = [
      this.postalCode,
      this.prefecture,
      this.city,
      this.street,
    ];
    if (this.building) {
      parts.push(this.building);
    }
    return parts.join(' ');
  }

  equals(other: Address): boolean {
    return (
      this.postalCode === other.postalCode &&
      this.prefecture === other.prefecture &&
      this.city === other.city &&
      this.street === other.street &&
      this.building === other.building
    );
  }
}

Entidades

Clase Base de Entidad

// domain/shared/entity.ts

export abstract class Entity<T> {
  protected readonly _id: T;

  constructor(id: T) {
    this._id = id;
  }

  get id(): T {
    return this._id;
  }

  equals(other: Entity<T>): boolean {
    if (other === null || other === undefined) {
      return false;
    }
    if (!(other instanceof Entity)) {
      return false;
    }
    return this._id === other._id;
  }
}

Entidad de Usuario

// domain/entities/user.ts

import { Entity } from '../shared/entity';
import { Email } from '../value-objects/email';
import { UserId } from '../value-objects/user-id';
import { UserCreatedEvent } from '../events/user-created';

interface UserProps {
  email: Email;
  name: string;
  role: UserRole;
  status: UserStatus;
  createdAt: Date;
  updatedAt: Date;
}

type UserRole = 'admin' | 'member' | 'guest';
type UserStatus = 'active' | 'inactive' | 'suspended';

export class User extends Entity<UserId> {
  private email: Email;
  private name: string;
  private role: UserRole;
  private status: UserStatus;
  private readonly createdAt: Date;
  private updatedAt: Date;

  private constructor(id: UserId, props: UserProps) {
    super(id);
    this.email = props.email;
    this.name = props.name;
    this.role = props.role;
    this.status = props.status;
    this.createdAt = props.createdAt;
    this.updatedAt = props.updatedAt;
  }

  // Método factory
  static create(email: Email, name: string): User {
    const id = UserId.create();
    const now = new Date();

    const user = new User(id, {
      email,
      name,
      role: 'member',
      status: 'active',
      createdAt: now,
      updatedAt: now,
    });

    // Publicar evento de dominio
    user.addDomainEvent(new UserCreatedEvent(user.id, email));

    return user;
  }

  // Método de reconstrucción (al restaurar desde repositorio)
  static reconstruct(id: UserId, props: UserProps): User {
    return new User(id, props);
  }

  // Lógica de negocio
  changeName(newName: string): void {
    if (!newName || newName.trim().length < 2) {
      throw new Error('Name must be at least 2 characters');
    }
    this.name = newName.trim();
    this.updatedAt = new Date();
  }

  changeEmail(newEmail: Email): void {
    if (this.email.equals(newEmail)) {
      return;
    }
    this.email = newEmail;
    this.updatedAt = new Date();
  }

  promote(): void {
    if (this.role === 'admin') {
      throw new Error('User is already an admin');
    }
    this.role = 'admin';
    this.updatedAt = new Date();
  }

  suspend(): void {
    if (this.status === 'suspended') {
      throw new Error('User is already suspended');
    }
    this.status = 'suspended';
    this.updatedAt = new Date();
  }

  activate(): void {
    if (this.status === 'active') {
      throw new Error('User is already active');
    }
    this.status = 'active';
    this.updatedAt = new Date();
  }

  isActive(): boolean {
    return this.status === 'active';
  }

  isAdmin(): boolean {
    return this.role === 'admin';
  }

  // Getters
  getEmail(): Email {
    return this.email;
  }

  getName(): string {
    return this.name;
  }

  getRole(): UserRole {
    return this.role;
  }

  getStatus(): UserStatus {
    return this.status;
  }

  getCreatedAt(): Date {
    return this.createdAt;
  }
}

Agregados

Agregado de Pedido

// domain/aggregates/order/order.ts

import { AggregateRoot } from '../../shared/aggregate-root';
import { OrderId } from './order-id';
import { OrderItem } from './order-item';
import { OrderStatus } from './order-status';
import { Money } from '../../value-objects/money';
import { UserId } from '../../value-objects/user-id';
import { OrderCreatedEvent } from '../../events/order-created';
import { OrderCompletedEvent } from '../../events/order-completed';

interface OrderProps {
  userId: UserId;
  items: OrderItem[];
  status: OrderStatus;
  shippingAddress: Address;
  createdAt: Date;
  updatedAt: Date;
}

export class Order extends AggregateRoot<OrderId> {
  private userId: UserId;
  private items: OrderItem[];
  private status: OrderStatus;
  private shippingAddress: Address;
  private readonly createdAt: Date;
  private updatedAt: Date;

  private constructor(id: OrderId, props: OrderProps) {
    super(id);
    this.userId = props.userId;
    this.items = props.items;
    this.status = props.status;
    this.shippingAddress = props.shippingAddress;
    this.createdAt = props.createdAt;
    this.updatedAt = props.updatedAt;
  }

  static create(userId: UserId, shippingAddress: Address): Order {
    const id = OrderId.create();
    const now = new Date();

    const order = new Order(id, {
      userId,
      items: [],
      status: OrderStatus.DRAFT,
      shippingAddress,
      createdAt: now,
      updatedAt: now,
    });

    return order;
  }

  // Agregar producto
  addItem(productId: ProductId, quantity: number, unitPrice: Money): void {
    this.assertCanModify();

    const existingItem = this.items.find(
      item => item.getProductId().equals(productId)
    );

    if (existingItem) {
      existingItem.increaseQuantity(quantity);
    } else {
      this.items.push(
        OrderItem.create(productId, quantity, unitPrice)
      );
    }

    this.updatedAt = new Date();
  }

  // Eliminar producto
  removeItem(productId: ProductId): void {
    this.assertCanModify();

    const index = this.items.findIndex(
      item => item.getProductId().equals(productId)
    );

    if (index === -1) {
      throw new Error('Item not found');
    }

    this.items.splice(index, 1);
    this.updatedAt = new Date();
  }

  // Confirmar pedido
  place(): void {
    if (this.items.length === 0) {
      throw new Error('Cannot place order with no items');
    }

    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Order is not in draft status');
    }

    this.status = OrderStatus.PLACED;
    this.updatedAt = new Date();

    this.addDomainEvent(new OrderCreatedEvent(
      this.id,
      this.userId,
      this.calculateTotal()
    ));
  }

  // Pago completado
  markAsPaid(): void {
    if (this.status !== OrderStatus.PLACED) {
      throw new Error('Order is not placed');
    }

    this.status = OrderStatus.PAID;
    this.updatedAt = new Date();
  }

  // Enviar
  ship(): void {
    if (this.status !== OrderStatus.PAID) {
      throw new Error('Order is not paid');
    }

    this.status = OrderStatus.SHIPPED;
    this.updatedAt = new Date();
  }

  // Entrega completada
  complete(): void {
    if (this.status !== OrderStatus.SHIPPED) {
      throw new Error('Order is not shipped');
    }

    this.status = OrderStatus.COMPLETED;
    this.updatedAt = new Date();

    this.addDomainEvent(new OrderCompletedEvent(this.id));
  }

  // Cancelar
  cancel(): void {
    if (this.status === OrderStatus.SHIPPED ||
        this.status === OrderStatus.COMPLETED) {
      throw new Error('Cannot cancel shipped or completed order');
    }

    this.status = OrderStatus.CANCELLED;
    this.updatedAt = new Date();
  }

  // Calcular monto total
  calculateTotal(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.calculateSubtotal()),
      Money.zero('JPY')
    );
  }

  private assertCanModify(): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Cannot modify non-draft order');
    }
  }

  // Getters
  getUserId(): UserId {
    return this.userId;
  }

  getItems(): ReadonlyArray<OrderItem> {
    return [...this.items];
  }

  getStatus(): OrderStatus {
    return this.status;
  }

  getShippingAddress(): Address {
    return this.shippingAddress;
  }
}

// order-item.ts (Entidad dentro del agregado)
export class OrderItem {
  private constructor(
    private readonly productId: ProductId,
    private quantity: number,
    private readonly unitPrice: Money
  ) {}

  static create(
    productId: ProductId,
    quantity: number,
    unitPrice: Money
  ): OrderItem {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    return new OrderItem(productId, quantity, unitPrice);
  }

  increaseQuantity(amount: number): void {
    if (amount <= 0) {
      throw new Error('Amount must be positive');
    }
    this.quantity += amount;
  }

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

  getProductId(): ProductId {
    return this.productId;
  }

  getQuantity(): number {
    return this.quantity;
  }

  getUnitPrice(): Money {
    return this.unitPrice;
  }
}

// order-status.ts (Objeto de valor)
export enum OrderStatus {
  DRAFT = 'DRAFT',
  PLACED = 'PLACED',
  PAID = 'PAID',
  SHIPPED = 'SHIPPED',
  COMPLETED = 'COMPLETED',
  CANCELLED = 'CANCELLED',
}

Repositorios

Interfaz de Repositorio

// domain/repositories/order-repository.ts

export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
  findByUserId(userId: UserId): Promise<Order[]>;
  delete(id: OrderId): Promise<void>;
  nextId(): OrderId;
}

// domain/repositories/user-repository.ts
export interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: UserId): Promise<User | null>;
  findByEmail(email: Email): Promise<User | null>;
  exists(email: Email): Promise<boolean>;
  delete(id: UserId): Promise<void>;
}

Implementación de Repositorio

// infrastructure/repositories/prisma-order-repository.ts

import { PrismaClient } from '@prisma/client';
import { OrderRepository } from '../../domain/repositories/order-repository';
import { Order } from '../../domain/aggregates/order/order';
import { OrderId } from '../../domain/aggregates/order/order-id';
import { OrderMapper } from '../mappers/order-mapper';

export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async save(order: Order): Promise<void> {
    const data = OrderMapper.toPersistence(order);

    await this.prisma.order.upsert({
      where: { id: data.id },
      create: {
        id: data.id,
        userId: data.userId,
        status: data.status,
        shippingAddress: data.shippingAddress,
        createdAt: data.createdAt,
        updatedAt: data.updatedAt,
        items: {
          create: data.items.map(item => ({
            productId: item.productId,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
          })),
        },
      },
      update: {
        status: data.status,
        updatedAt: data.updatedAt,
        items: {
          deleteMany: {},
          create: data.items.map(item => ({
            productId: item.productId,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
          })),
        },
      },
    });
  }

  async findById(id: OrderId): Promise<Order | null> {
    const data = await this.prisma.order.findUnique({
      where: { id: id.getValue() },
      include: { items: true },
    });

    if (!data) return null;

    return OrderMapper.toDomain(data);
  }

  async findByUserId(userId: UserId): Promise<Order[]> {
    const orders = await this.prisma.order.findMany({
      where: { userId: userId.getValue() },
      include: { items: true },
      orderBy: { createdAt: 'desc' },
    });

    return orders.map(OrderMapper.toDomain);
  }

  async delete(id: OrderId): Promise<void> {
    await this.prisma.order.delete({
      where: { id: id.getValue() },
    });
  }

  nextId(): OrderId {
    return OrderId.create();
  }
}

Servicios de Dominio

// domain/services/order-domain-service.ts

export class OrderDomainService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly productRepository: ProductRepository,
    private readonly inventoryService: InventoryService
  ) {}

  async canPlaceOrder(order: Order): Promise<boolean> {
    // Verificar stock
    for (const item of order.getItems()) {
      const available = await this.inventoryService.checkAvailability(
        item.getProductId(),
        item.getQuantity()
      );

      if (!available) {
        return false;
      }
    }

    return true;
  }

  async calculateShippingFee(order: Order): Promise<Money> {
    const total = order.calculateTotal();
    const address = order.getShippingAddress();

    // Umbral de envío gratuito
    if (total.isGreaterThan(Money.create(10000, 'JPY'))) {
      return Money.zero('JPY');
    }

    // Costo de envío por región
    const baseFee = this.getBaseFeeByRegion(address.getPrefecture());
    return baseFee;
  }

  private getBaseFeeByRegion(prefecture: string): Money {
    const hokkaido = ['Hokkaido'];
    const okinawa = ['Okinawa'];

    if (hokkaido.includes(prefecture)) {
      return Money.create(1500, 'JPY');
    }
    if (okinawa.includes(prefecture)) {
      return Money.create(2000, 'JPY');
    }
    return Money.create(800, 'JPY');
  }
}

Servicios de Aplicación

// application/services/order-application-service.ts

export class OrderApplicationService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly userRepository: UserRepository,
    private readonly orderDomainService: OrderDomainService,
    private readonly eventPublisher: DomainEventPublisher,
    private readonly unitOfWork: UnitOfWork
  ) {}

  async createOrder(command: CreateOrderCommand): Promise<OrderDto> {
    return this.unitOfWork.execute(async () => {
      // Verificar existencia del usuario
      const user = await this.userRepository.findById(
        UserId.create(command.userId)
      );

      if (!user) {
        throw new UserNotFoundError(command.userId);
      }

      // Crear pedido
      const order = Order.create(
        user.id,
        Address.create(command.shippingAddress)
      );

      // Agregar productos
      for (const item of command.items) {
        order.addItem(
          ProductId.create(item.productId),
          item.quantity,
          Money.create(item.unitPrice, 'JPY')
        );
      }

      // Verificar si se puede confirmar el pedido
      const canPlace = await this.orderDomainService.canPlaceOrder(order);
      if (!canPlace) {
        throw new InsufficientInventoryError();
      }

      // Confirmar pedido
      order.place();

      // Guardar
      await this.orderRepository.save(order);

      // Publicar eventos de dominio
      await this.eventPublisher.publishAll(order.pullDomainEvents());

      return OrderDto.fromDomain(order);
    });
  }

  async getOrder(orderId: string): Promise<OrderDto | null> {
    const order = await this.orderRepository.findById(
      OrderId.create(orderId)
    );

    if (!order) return null;

    return OrderDto.fromDomain(order);
  }

  async cancelOrder(orderId: string): Promise<void> {
    return this.unitOfWork.execute(async () => {
      const order = await this.orderRepository.findById(
        OrderId.create(orderId)
      );

      if (!order) {
        throw new OrderNotFoundError(orderId);
      }

      order.cancel();

      await this.orderRepository.save(order);
      await this.eventPublisher.publishAll(order.pullDomainEvents());
    });
  }
}

Resumen

DDD es efectivo en proyectos con lógica de negocio compleja.

Selección de Patrones Tácticos

PatrónUso
Objeto de ValorValores inmutables, encapsulación de reglas
EntidadObjetos con identificador
AgregadoLímite de consistencia, unidad de transacción
RepositorioAbstracción de persistencia
Servicio de DominioLógica que no pertenece a entidades

Puntos para la Adopción

  1. Lenguaje Ubicuo: Crear un lenguaje común con expertos del dominio
  2. Contexto Delimitado: Dividir el sistema con la granularidad adecuada
  3. Adopción Gradual: Comenzar por el dominio core
  4. Pruebas: Tests unitarios de la lógica de dominio

DDD tiene una curva de aprendizaje alta, pero aplicándolo correctamente se pueden construir sistemas altamente mantenibles.

Enlaces de Referencia

← Volver a la lista