Skip to content

Domain-Driven Design (DDD)

Domain-Driven Design (DDD) es un enfoque para el desarrollo de software complejo que pone el dominio de negocio en el centro de todas las decisiones técnicas. Fue introducido por Eric Evans en 2003 y sigue siendo el modelo más sólido para construir sistemas donde la lógica de negocio es el núcleo del valor, no la infraestructura que lo rodea.

En GM Transport, DDD es el lenguaje con el que se diseñan los servicios de backend. Un desarrollador que entiende DDD puede leer el código de GM Fiscal y reconocer inmediatamente conceptos del negocio: Solicitud, Conciliacion, Empresa, ReporteR21. El código habla el idioma del negocio.

El problema del software sin dominio

El síntoma más común del software sin DDD es la dispersión de lógica de negocio. La regla “una solicitud no puede cancelarse después de estar conciliada” aparece en tres lugares: en el handler HTTP, en el servicio de infraestructura y en un trigger de base de datos. Cuando la regla cambia, hay que encontrar y actualizar los tres. Ningún test cubre el caso completo.

DDD resuelve esto localizando toda la lógica de negocio en el dominio, que es la única fuente de verdad para las reglas del negocio.

Lenguaje Ubicuo

El Lenguaje Ubicuo (Ubiquitous Language) es el vocabulario compartido entre desarrolladores y expertos del negocio. Los mismos términos que usa el contador —conciliación, solicitud, RFC solicitante, factura emitida— son los mismos términos que aparecen en el código.

El lenguaje ubicuo no es solo buenas prácticas de nomenclatura. Es una herramienta de diseño: si el código usa términos que los expertos del negocio no reconocen, hay un desacoplamiento conceptual que producirá errores y malentendidos.

// Con lenguaje ubicuo — el experto de negocio reconoce esto
type Solicitud struct {
UUID string
RFCSolicitante string
PeriodoInicio time.Time
PeriodoFin time.Time
TiposFactura []TipoFactura
Estatus EstatusSolicitud
}
func (s *Solicitud) IniciarProcesamiento() error {
if s.Estatus != ListaParaConciliar {
return ErrTransicionInvalida(s.Estatus, EnProceso)
}
s.Estatus = EnProceso
return nil
}
// Sin lenguaje ubicuo — el experto de negocio no entiende esto
type RequestDTO struct {
ID int
RFCID int
StartDT time.Time
EndDT time.Time
TypeIDs []int
StatusID int
}

Bounded Contexts

Un Bounded Context (Contexto Delimitado) es la frontera explícita dentro de la cual un modelo de dominio es válido y coherente. El mismo término puede significar cosas diferentes en contextos distintos.

En GM Fiscal, Factura en el contexto de conciliacion tiene campos distintos que Factura en el contexto de reporte. Esto no es inconsistencia: es la aplicación correcta de bounded contexts. Cada contexto tiene su propio modelo que refleja exactamente lo que ese contexto necesita.

graph LR
    subgraph BC_CONC["Conciliacion Context"]
        FC["FacturaConciliada\n(UUID, Origen, Conciliado,\nImpuestosERP, ImpuestosProdigia)"]
    end

    subgraph BC_REP["Reporte Context"]
        FR["FacturaReporte\n(UUID, TipoComprobante,\nRFCEmisor, RFCReceptor,\nMontos, Tasas)"]
    end

    subgraph BC_SOL["Solicitud Context"]
        FS["SolicitudFactura\n(UUID, Período,\nTiposFactura)"]
    end

    BC_CONC -.->|"Anti-Corruption Layer\n(conversión explícita)"| BC_REP

Building Blocks del DDD táctico

Entidad

Una Entidad tiene identidad propia que persiste a través del tiempo. Dos entidades son iguales si tienen el mismo identificador, aunque sus otros atributos difieran.

// Entidad — tiene identidad (UUID)
type Empresa struct {
ID uint
RFC string
RazonSocial string
ProdigiaRFC *string
ServicesRFC *string
}
// Dos empresas son iguales solo si tienen el mismo RFC
func (e Empresa) Equals(other Empresa) bool {
return strings.EqualFold(e.RFC, other.RFC)
}
// Entidad en Dart (Flutter)
class Empresa extends Entity<EmpresaId> {
final String rfc;
final String razonSocial;
const Empresa({
required EmpresaId id,
required this.rfc,
required this.razonSocial,
}) : super(id: id);
}

Value Object

Un Value Object no tiene identidad: dos value objects son iguales si todos sus atributos son iguales. Son inmutables.

// Value Object — inmutable, igualdad por valor
type Periodo struct {
Inicio time.Time
Fin time.Time
}
func (p Periodo) Equals(other Periodo) bool {
return p.Inicio.Equal(other.Inicio) && p.Fin.Equal(other.Fin)
}
func (p Periodo) ContieneFecha(fecha time.Time) bool {
return (fecha.Equal(p.Inicio) || fecha.After(p.Inicio)) &&
(fecha.Equal(p.Fin) || fecha.Before(p.Fin))
}
// Value Object en TypeScript (NestJS)
export class RFC {
private readonly value: string;
constructor(value: string) {
if (!RFC.isValid(value)) {
throw new InvalidRFCError(value);
}
this.value = value.toUpperCase();
}
static isValid(value: string): boolean {
return /^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/.test(value.toUpperCase());
}
equals(other: RFC): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}

Agregado

Un Agregado es un clúster de entidades y value objects que se tratan como una unidad. Tiene una raíz de agregado (Aggregate Root) que es el único punto de entrada para modificar el estado del clúster.

// Solicitud es la raíz del agregado
// Solo se modifica a través de sus métodos, nunca directamente
type Solicitud struct {
UUID string
Estatus EstatusSolicitud
FacturasUUID []string // referencias a facturas, no las facturas completas
Periodo Periodo // value object embebido
}
// La transición de estado es responsabilidad del agregado
func (s *Solicitud) Conciliar() error {
if s.Estatus == Conciliada {
return ErrTransicionInvalida(s.Estatus, Conciliada)
}
if s.Estatus != EnProceso {
return ErrTransicionInvalida(s.Estatus, Conciliada)
}
s.Estatus = Conciliada
return nil
}

Repositorio

Un Repositorio abstrae el acceso a los datos y devuelve agregados, no filas de base de datos. El dominio define la interfaz; la infraestructura la implementa.

// Repositorio — interfaz en el dominio
type SolicitudRepository interface {
FindByUUID(uuid string) (Solicitud, error)
FindByRFC(rfc string) ([]Solicitud, error)
Save(s Solicitud) error
}
// Repositorio en NestJS
export interface ConciliacionRepository {
findByUUID(uuid: string): Promise<Conciliacion | null>;
findByRFC(rfc: string): Promise<Conciliacion[]>;
save(conciliacion: Conciliacion): Promise<void>;
}

Servicio de Dominio

Un Servicio de Dominio contiene lógica de negocio que no pertenece a ninguna entidad ni value object específico. Es stateless y opera sobre múltiples entidades.

// Servicio de dominio — MakeConciliacion es pura (sin efectos secundarios)
func MakeConciliacion(
rfcSolicitante string,
uuidSolicitud string,
facturasERP []factura.FacturaERP,
facturasSAT []factura.FacturaProdigia,
previosUUIDs []string,
) Conciliacion {
// Lógica pura — sin I/O, sin estado, determinística
indexERP := indexarPorUUID(facturasERP)
indexSAT := indexarPorUUID(facturasSAT)
return clasificar(indexERP, indexSAT, previosUUIDs)
}

Patrones estratégicos de DDD

Context Map

El Context Map documenta cómo se relacionan los bounded contexts entre sí. Hay varios patrones de integración:

PatrónDescripciónCuándo usarlo
Shared KernelCódigo compartido entre dos contextosContextos con alto acoplamiento controlado
Anti-Corruption Layer (ACL)Traducción explícita entre modelosAl integrar con sistemas legados o externos
Open Host ServiceUn contexto expone una API pública estableEl contexto es consumido por muchos clientes
Published LanguageFormato de intercambio estándarEventos de integración entre microservicios

En GM Transport, la integración con el SAT vía Prodigia o Hades utiliza una ACL: los DTOs externos se convierten explícitamente al modelo de dominio antes de entrar al dominio.

Strangler Fig

El Strangler Fig es el patrón de migración incremental para reemplazar un sistema legado. En lugar de una reescritura completa, se envuelve el código legado con adaptadores y se migra bounded context por bounded context hasta que el legado desaparece.

GM Fiscal aplica este patrón activamente: el código en controller/, handlers/ y services/ se migra a la arquitectura hexagonal DDD incrementalmente, con tombstones explícitos que marcan el código a eliminar.

Aplicación en el stack de GM Transport

TecnologíaAplicación de DDD
GoReferencia principal: GM Fiscal Backend. Entidades sin tags GORM, repositorios como interfaces, composition root en main.go
NestJSMódulos por bounded context, repositorios como providers inyectables, DTOs separados de entidades
Flutter/DartEntidades y repositorios en la capa domain, Either<Failure, T> para manejo de errores
FastAPISchemas Pydantic como value objects, servicios como domain services, repositorios con SQLAlchemy o abstractos

Resumen

  • DDD organiza el código alrededor del dominio de negocio. El código habla el mismo lenguaje que los expertos del negocio.
  • El Lenguaje Ubicuo elimina la traducción constante entre el vocabulario técnico y el del negocio.
  • Los Bounded Contexts delimitan dónde un modelo es válido. El mismo término puede tener modelos distintos en contextos distintos.
  • Los building blocks tácticos son: Entidad (identidad), Value Object (igualdad por valor, inmutable), Agregado (unidad con raíz), Repositorio (abstracción de persistencia) y Servicio de Dominio (lógica sin estado entre entidades).
  • DDD y Arquitectura Hexagonal se complementan: DDD define qué hay en el interior del hexágono; Hexagonal define cómo se aisla ese interior del exterior.