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 estotype 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 estotype 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 RFCfunc (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 valortype 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 directamentetype 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 agregadofunc (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 dominiotype SolicitudRepository interface { FindByUUID(uuid string) (Solicitud, error) FindByRFC(rfc string) ([]Solicitud, error) Save(s Solicitud) error}// Repositorio en NestJSexport 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ón | Descripción | Cuándo usarlo |
|---|---|---|
| Shared Kernel | Código compartido entre dos contextos | Contextos con alto acoplamiento controlado |
| Anti-Corruption Layer (ACL) | Traducción explícita entre modelos | Al integrar con sistemas legados o externos |
| Open Host Service | Un contexto expone una API pública estable | El contexto es consumido por muchos clientes |
| Published Language | Formato de intercambio estándar | Eventos 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ía | Aplicación de DDD |
|---|---|
| Go | Referencia principal: GM Fiscal Backend. Entidades sin tags GORM, repositorios como interfaces, composition root en main.go |
| NestJS | Módulos por bounded context, repositorios como providers inyectables, DTOs separados de entidades |
| Flutter/Dart | Entidades y repositorios en la capa domain, Either<Failure, T> para manejo de errores |
| FastAPI | Schemas 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.