- Implemented ProformaDuplicator service for duplicating proforma documents. - Added tests for duplicating proformas, including scenarios for successful duplication, audit failure propagation, and payment term adaptations. - Created ProformaStatusChanger service to handle status changes of proformas with corresponding tests. - Developed ProformaArchiver service for unarchiving proformas and added tests to verify functionality. - Introduced a test fixture for creating proforma instances for testing purposes.
17 KiB
DOCUMENTO DE DISEÑO — ERP MODULAR BACKEND (ESTADO CONSOLIDADO)
1. Contexto general
ERP modular en monorepo con:
- PNPM
- Turborepo
- Node.js ≥ 22
- TypeScript
- ExpressJS
- MariaDB
- Sequelize v6
- Zod v4
Arquitectura basada en:
- DDD pragmático
- Clean Architecture estricta
- Separación clara Domain / Application / Infrastructure
- Composition root explícito por módulo
- Sin contenedor global implícito
- Sin DI mágico
- Sin Service Locator
- Sin dependencias cruzadas entre módulos
Principios permanentes:
- SOLID
- KISS
- DRY razonable (claridad > abstracción prematura)
- Fail Fast
- Idempotencia
- Separación IO / lógica de negocio
- Evitar sobreingeniería sistemáticamente
2. Concepto de módulo
Un módulo es una unidad funcional aislada que:
- Tiene su propio dominio.
- Tiene su propia capa Application.
- Tiene su propia capa Infrastructure.
- Expone servicios explícitamente a otros módulos.
- No accede directamente a infraestructura interna de otros módulos.
Cada módulo implementa el contrato IModuleServer.
Estructura conceptual:
<module>/
api/
application/
infrastructure/
No se comparte código cruzado entre módulos salvo a través de:
- Core
- Servicios expuestos explícitamente
3. Rol del módulo Core
core contiene:
- Funcionalidades técnicas reutilizables.
- Infraestructura transversal.
- Interfaces compartidas.
- Utilidades comunes.
- Contratos base.
Ubicación:
core/api/
application/
infra/
Reglas:
- Core no conoce módulos funcionales.
- Core no depende de customer-invoices ni de ningún otro módulo de negocio.
- Core solo define abstracciones y servicios reutilizables.
Core es el único módulo permitido como dependencia técnica común.
4. Separación estricta de capas
Domain
Contiene:
- Agregados
- Entidades
- Value Objects
- Reglas de negocio
- Invariantes
No conoce:
- HTTP
- Sequelize
- DTO
- Infrastructure
Application
Contiene:
- Use cases
- Servicios de aplicación (
Creator,Updater,Issuer, etc.) - Mappers DTO → Props
- Interfaces de repositorios
- Snapshot builders
No conoce:
- Express
- Sequelize
- Models ORM
Infrastructure
Contiene:
- Controllers Express
- DTO HTTP
- Mappers Request DTO HTTP → Props
- Implementaciones Sequelize
- DomainMapper (persistencia ↔ dominio)
- DI del módulo
Nunca:
- Application depende de Infrastructure
- Domain depende de Application
4.1 Estructura interna de un módulo funcional
Ejemplo conceptual (customer-invoices):
customer-invoices/api/
application/
infrastructure/
4.1.1 Application
Contiene:
- Use cases
- Servicios de lectura (Finder)
- Servicios de escritura (WriteService)
- Factories
- Servicios de numeración
- Snapshot builders
- Application models
- Interfaces de repositorio
- Interfaces de servicios externos
Application:
- No conoce Express
- No conoce Sequelize
- No conoce filesystem
- No conoce implementaciones concretas
Solo depende de:
- Dominio
- Interfaces
- Core
4.1.2 Infrastructure
Contiene:
- Implementaciones Sequelize
- Implementaciones de repositorios
- Mappers dominio ↔ ORM
- Controllers Express
- Rutas
- Implementaciones de servicios externos
- DI del módulo
Infrastructure depende de:
- Application
- Dominio
- Core
Nunca al revés.
4.2 Snapshot builders (materialización de salida)
Los snapshot builders pertenecen a la capa Application y son responsables de transformar modelos de aplicación o agregados en estructuras serializables para la capa HTTP.
Rol en el flujo
UseCase
↓
SnapshotBuilder
↓
DTO (response)
↓
Controller (HTTP)
Principio clave
El contrato de salida está definido exclusivamente por los DTOs (schemas Zod). El snapshot builder solo los materializa.
Esto refuerza la separación:
- DTO → contrato
- Builder → transformación
Tipado obligatorio
Todo snapshot builder debe tiparse contra el DTO inferido:
ISnapshotBuilder<Input, OutputDTO>
donde:
OutputDTO = z.infer<typeof SomeSchema>
Reglas obligatorias
- No definir interfaces manuales duplicando DTOs.
- No introducir tipos intermedios si no aportan valor real.
- El builder debe ser determinista y puro.
- El builder no puede depender de infraestructura.
- El builder no puede contener lógica de negocio.
Relación con Use Cases
Los use cases:
- ejecutan lógica
- orquestan servicios
- construyen snapshots mediante builders
- devuelven DTOs (no entidades)
const items = customers.map(customerSummaryBuilder.toOutput);
return Result.ok({
page,
per_page,
...
items,
});
Relación con Domain
El builder:
- nunca modifica el dominio
- nunca valida invariantes
- nunca llama a
create()nirehydrate()
Solo lee estado ya válido.
Principio de consistencia
Si el DTO define
null, el builder debe emitirnull. Si el DTO define enum, el builder debe emitir un valor válido del enum.
Nunca:
""en lugar denull- strings genéricos en lugar de enums
- boolean como string
Objetivo arquitectónico
Garantizar:
- contratos HTTP estables
- desacoplo dominio ↔ transporte
- ausencia de duplicación de tipos
- consistencia transversal en todos los módulos
Resultado
Con estos cambios:
- eliminas duplicación (
interface vs zod) - alineas builders con DTOs
- fuerzas consistencia en outputs (justo tu problema actual)
- mantienes KISS (sin introducir capas nuevas)
- refuerzas Clean Architecture sin añadir complejidad
Siguiente paso lógico
Te recomiendo ahora hacer un refactor pequeño pero crítico:
- eliminar todos los
I*Snapshot - extraer todos los
*SummarySchema - tipar builders con
z.infer
Esto te va a eliminar el 80% de inconsistencias que estás viendo.
4.3. Composition Root por módulo
Cada módulo:
- Construye sus dependencias en
init() - Registra modelos en
registerDependencies() - Expone servicios explícitamente si quiere que otros módulos los usen
No se modifica el contrato de módulos.
No existe contenedor global compartido implícito.
Cada módulo controla su propio wiring.
4.4. Registro de dependencias entre módulos
Colaboración entre módulos se realiza mediante:
registerDependencies()
Regla:
- Solo se exponen servicios útiles.
- No se exponen repositorios.
- No se exponen detalles de infraestructura.
- Se exponen interfaces claras.
Ejemplo conceptual:
return {
models,
services: {
issuedInvoiceFinder,
customerInvoiceWriteService
}
}
Otros módulos solo consumen lo que fue registrado.
Nunca se importa directamente infraestructura de otro módulo.
5. Flujo semántico obligatorio
Regla estructural fija:
RequestDTO (Infrastructure)
↓
PropsMapper (Application)
↓
Aggregate
Prohibido:
- DTO → Aggregate
- Saltar niveles
Cada nivel tiene nombre distinto. No se mezclan responsabilidades. No hay ambigüedad.
6. Dominio de facturación (decisión consolidada)
Se elimina el agregado híbrido CustomerInvoice.
Se divide en:
Proforma (agregado independiente)
- Modelo dinámico
- Calcula totales
- Agrupa impuestos dinámicamente
- Mutable (no contable)
- No conoce Verifactu
- No materializa impuestos
IssuedInvoice (agregado independiente)
- Snapshot materializado
- Totales congelados
- Impuestos materializados
- Items materializados
- No recalcula nada
- Solo permite transición de estado contable
- Almacena Verifactu
Principio aplicado:
Snapshot models must not recalculate.
IssuedInvoice confía en el materializer. No valida cálculos internos. No recalcula dinámicamente.
7. Repositorios
Se elimina ICustomerInvoiceRepository.
Se crean:
IProformaRepositoryIIssuedInvoiceRepository
Cada repositorio gestiona un solo agregado.
Actualmente se mantiene una sola tabla customer_invoice, pero:
- El dominio no conoce
is_proforma. - El filtrado vive exclusivamente en Infrastructure.
Se ha decidido duplicar repositorios explícitamente. No usar clases base compartidas. No usar repositorios genéricos.
8. Inyección de dependencias
- Constructor injection explícita.
- Sin registry dinámico.
- Sin service locator.
- Si hay más de 2 dependencias → usar objeto
deps.
Ejemplo:
constructor(private readonly deps: {
mapper: IProformaDomainMapper;
database: Database;
}) {}
Wiring en DI del módulo.
9. Convención obligatoria de naming para mappers
A. HTTP (Infrastructure)
Request DTO
Formato:
<Operation><Aggregate>RequestDTO
Ejemplo:
CreateProformaRequestDTO
HTTP → Command
Formato:
<Operation><Aggregate>RequestMapper
Ejemplo:
CreateProformaRequestMapper
B. Application
Command
Formato:
<Operation><Aggregate>Command
Ejemplo:
CreateProformaCommand
Command → Props
Formato:
<Operation><Aggregate>PropsMapper
Ejemplo:
CreateProformaPropsMapper
C. Persistencia (Infrastructure / Sequelize)
Un único mapper bidireccional por agregado:
Formato:
<Aggregate>DomainMapper
Métodos obligatorios:
toPersistence()toDomain()
Ejemplo:
ProformaDomainMapperIssuedInvoiceDomainMapper
Reglas anti-ambigüedad
Prohibido:
CreateProformaMapperDtoMapperTransformerAdaptersin necesidad real
El sufijo debe indicar claramente la dirección.
10. Servicios de aplicación
Se adoptan servicios pequeños y explícitos:
ProformaCreatorProformaUpdaterProformaDeleterProformaIssuer
Reglas:
- Responsabilidad única.
- No contener lógica HTTP.
- No acceder directamente a Sequelize.
- No mezclar múltiples operaciones en un único servicio.
Se evita CommandService.
11. Casos de uso
Los use cases:
- Son ligeros.
- Orquestan.
- Gestionan transacción.
- Delegan en servicios.
- Construyen snapshot de salida.
- Devuelven
Result.
No deben:
- Mapear DTO.
- Construir agregados.
- Persistir directamente.
- Ejecutar lógica de negocio.
12. Controllers Express
Ubicación:
infrastructure/express/<aggregate>/
Responsabilidad:
- HTTP → RequestDTO
- RequestDTO → Command (mediante RequestMapper)
- Llamar UseCase
- Traducir Result → HTTP
Nunca:
- Acceder a repositorios.
- Construir agregados.
- Ejecutar lógica de dominio.
13. Colaboración entre módulos
Reglas:
- Solo se exponen servicios explícitos.
- No se exponen repositorios.
- No se accede a infraestructura interna.
- Core es la única dependencia técnica común.
No introducir Event Bus global ni abstracciones prematuras.
14. Decisiones clave adoptadas
- Separación real Proforma / IssuedInvoice.
- Snapshot materializado para facturas emitidas.
- Un repositorio por agregado.
- Una sola tabla por ahora.
- Duplicación preferida a abstracción prematura.
- Inyección explícita de dependencias.
- Mapper bidireccional único por agregado (persistencia).
- Un mapper por operación (RequestMapper, PropsMapper).
- Flujo obligatorio RequestDTO → Command → Props → Aggregate.
15. Convenciones de naming adoptadas
Interfaces:
-
Prefijo obligatorio
IICustomerInvoiceRepositoryIIssuedInvoiceFinderICustomerInvoiceFactoryICustomerInvoiceNumberService
Servicios:
*Finder→ lectura*WriteService→ escritura*Factory→ construcción*NumberService→ numeración
Se evita:
CommandService- Nombres ambiguos
- ServiceLocator
16. Separación lectura / escritura (pragmática)
No se implementa CQRS formal completo.
Pero sí se separa:
- Finder (consultas)
- WriteService (mutaciones)
Motivo:
- Claridad semántica
- Mejor legibilidad
- Mejor separación de responsabilidades
Sin introducir sobreingeniería.
17. Mappers
Ubicación:
infrastructure/persistence/sequelize/mappers/
Tipos:
- domain/
- list/
Los mappers:
- Solo los usa el repositorio.
- No se exponen a Application.
- No se usan fuera de Infrastructure.
18. Reglas estrictas de dependencia
Dominio:
- No depende de nada externo.
Application:
- Depende solo de Dominio + interfaces + Core.
Infrastructure:
- Depende de Application + Dominio + Core.
Nunca:
- Application → Infrastructure
- Dominio → Application
- Módulo A → Infra de módulo B
19. Desarrollo de nuevos módulos
Para crear un nuevo módulo:
-
Crear estructura:
new-module/api/application new-module/api/infrastructure -
Definir:
- Dominio
- Interfaces en Application
- Use cases
- Servicios de lectura/escritura
-
Implementar Infra:
- Repositorios
- Controllers
- DI
-
Registrar módulo en el loader global.
-
Exponer solo servicios necesarios mediante
registerDependencies.
No copiar lógica transversal: usar Core.
20. Colaboración entre módulos
Para que módulo A use lógica de módulo B:
- B debe exponer servicio explícito.
- A debe declarar dependencia.
- El módulo loader debe garantizar orden de carga.
- Se usa interfaz, no implementación concreta.
Nunca:
- Importar ficheros directamente.
- Acceder a infraestructura interna.
- Compartir repositorios.
21. Evitar overengineering
No introducir:
- Event bus global si no es necesario.
- Contenedores DI complejos.
- Domain events si no hay necesidad real.
- CQRS formal.
- Versionado prematuro.
- Abstracciones sin uso inmediato.
Cada abstracción debe tener un caso real.
22. Estilo de trabajo y preferencias
Responder:
- En castellano.
- Código en inglés.
- Comentarios en castellano.
- Directo y técnico.
- Sin introducciones innecesarias.
- Sin emoticonos.
- Señalar incoherencias.
- Proponer refactors si mejoran claridad.
- Priorizar arquitectura correcta.
- Minimizar tokens sin perder precisión.
- Revisar siempre riesgo de overengineering.
- Mantener coherencia modular.
- Diseñar nuevos módulos.
- Garantizar separación correcta de capas.
- Establecer contratos claros entre módulos.
- Evitar dependencias cruzadas indebidas.
- Evolucionar el sistema sin romper principios estructurales.
Ante cualquier nueva propuesta:
- Validar necesidad real.
- Evaluar impacto estructural.
- Evitar abstracción anticipada.
23. Convención de creación y rehidratación (obligatoria)
23.1 Principio general
Se establecen dos operaciones semánticamente distintas en el dominio:
create() → construcción funcional (nacimiento en el dominio)
rehydrate() → reconstrucción desde persistencia
Estas operaciones no son intercambiables y deben mantenerse separadas en todo el sistema.
23.2 Entidades y Aggregate Roots
create()
static create(props: CreateProps, id?: UniqueID): Result<Entity, Error>
Responsabilidades:
- Crear una nueva instancia de dominio
- Aplicar invariantes de negocio
- Validar datos de entrada
- Construir entidades internas
- Registrar domain events (cuando se implementen)
Características:
- Devuelve
Result - Puede fallar
- Representa “nacimiento” del objeto en el dominio
rehydrate()
static rehydrate(props: InternalProps, id: UniqueID): Entity
Responsabilidades:
- Reconstruir una instancia desde persistencia
- Restaurar estado previamente válido
Características:
- No devuelve
Result - No valida reglas de creación
- No genera eventos
- No ejecuta lógica adicional
Reglas obligatorias
create()NO se usa en infraestructurarehydrate()NO se usa en casos de usocreate()yrehydrate()NO deben compartir lógica implícita- El constructor debe ser
protected
23.3 Value Objects
create()
static create(props: Props): Result<ValueObject, Error>
Responsabilidades:
- Validar el valor
- Normalizar si aplica
- Construir una instancia inmutable
Características:
- Sin identidad
- Sin eventos
- Sin distinción entre creación y reconstrucción
Reglas obligatorias
- Los Value Objects NO tienen
rehydrate() - Los Value Objects siempre se reconstruyen mediante
create() - Los Value Objects no dependen de persistencia
23.4 Uso en el sistema
Flujo de creación funcional
Controller
↓
UseCase
↓
Aggregate.create()
↓
Repository.save()
Flujo de reconstrucción
Repository
↓
DomainMapper
↓
Aggregate.rehydrate()
23.5 Reglas de diseño adicionales
update()nunca debe usarcreate()nirehydrate()- Las entidades mutan su estado, no se recrean
- Los Value Objects sí pueden recrearse libremente
- El dominio no debe asumir origen de los datos
- Infraestructura es la única capa que usa
rehydrate()
23.6 Naming adicional (opcional)
Se permiten factories específicas en Value Objects cuando mejore la claridad:
UtcDate.createFromISO(...)
Money.fromDecimal(...)
TaxCode.fromString(...)
Estas no sustituyen a create(), sino que complementan su uso.
23.7 Principio final
create() = nuevo estado válido en el dominio rehydrate() = estado ya válido restaurado
Esta distinción es obligatoria en todo el ERP.