1 — Colapsar el pipeline de intake de leads
Strong
ports & adapters
in-process
Archivos involucrados
controllers/leadController.ts ·
controllers/leadValidatorController.ts ·
controllers/leadEnricherController.ts ·
controllers/leadScorerController.ts ·
controllers/leadAssignerController.ts ·
controllers/leadNotifierController.ts
Before — 6 módulos superficiales en cadena
leadController
leadValidatorController
leadEnricherController
leadScorerController
leadAssignerController
leadNotifierController ⚠ leak
Añadir canal WhatsApp → editar 6 archivos
After — un módulo profundo: LeadIntake
LeadIntake.process(input)
validate · enrich · score · assign · notify
WebFormAdapter
LinkedInAdapter
WhatsAppAdapter
Nuevo canal → 1 adapter. Cero cambios internos.
Problema: seis controllers superficiales se encadenan sin imponer ningún comportamiento propio — la interfaz de cada uno es casi tan compleja como su implementación. El test de borrado confirma: eliminar cualquiera desplaza la misma complejidad al siguiente.
Solución: colapsar los seis en un módulo profundo LeadIntake con una sola interfaz process(LeadInput): Promise<Lead> y exponer las fuentes de datos (Clearbit, notificadores) como adapters intercambiables detrás de seams.
- leverage: una interfaz, todos los casos de uso de captura
- locality: validación, enriquecimiento y scoring en un solo lugar
- tests apuntan a
LeadIntake.process(), no a 6 mock chains
- canal nuevo = 1 adapter, 0 ediciones internas
2 — Eliminar los pass-through wrappers de infraestructura
Strong
local-substitutable
Archivos involucrados
services/prismaService.ts ·
services/redisService.ts ·
services/bullService.ts
Before — mass diagram (interface ≈ implementation)
interface
PrismaClient
pass-thru
impl
PrismaClient
(1 línea)
prismaService
interface
ioredis
pass-thru
impl
ioredis
(1 línea)
redisService
interface
BullMQ
pass-thru
impl
BullMQ
(1 línea)
bullService
After — mass diagram (seam justificado o suprimido)
ILeadRepository
(3 métodos)
PrismaLeadRepo
impl completa
+ retry logic
+ mappers
PrismaLeadRepo
Problema: los tres wrappers son módulos con interfaz tan ancha como su implementación — pasan la librería tal cual. El test de borrado: elimínense y la complejidad desaparece (no se redistribuye). No son módulos, son ruido.
Solución: eliminar redisService y bullService (usar BullMQ e ioredis directamente donde se necesiten); convertir prismaService en un ILeadRepository real con lógica de retry, mapping y seam testeable.
- depth:
ILeadRepository absorbe retry, mapping, paginación
- leverage: 3 métodos de interfaz, toda la lógica de persistencia dentro
- tests usan
InMemoryLeadRepository — sin Prisma, sin Redis
- elimina 2 archivos sin reemplazarlos
3 — Extraer la lógica de scoring a un módulo profundo
Strong
in-process
Archivos involucrados
utils/scoreUtils.ts ·
utils/leadUtils.ts ·
controllers/leadScorerController.ts
Before — call-graph disperso
leadScorerController
leadUtils.normalizeEmail ⚠
dateUtils.isBusinessHour ⚠
Tests del scorer mockean 4 módulos externos
After — LeadScorer como módulo profundo
LeadScorer.score(lead)
— interno, no expuesto —
normalizeEmail
weightedAvg
isBusinessHour
Tests del scorer: 1 interfaz, 0 mocks externos
Problema: scoreUtils y partes de leadUtils son lógica de dominio fragmentada en helpers — no hay un seam donde se pueda interceptar o testear el scoring completo sin mockear todos sus colaboradores.
Solución: crear un módulo LeadScorer con interfaz score(lead: Lead): LeadScore; las funciones de scoreUtils y los helpers relevantes de leadUtils se vuelven implementación privada interna.
- locality: toda la lógica de scoring en un lugar
- interface shrinks:
score(lead) reemplaza 5 funciones exportadas
- tests puros: entradas → salida, sin mocks de infraestructura
- reglas de negocio (pesos, factores) visibles y concentradas
4 — Invertir la dependencia jobs → domain (no controllers)
Worth exploring
ports & adapters
Archivos involucrados
jobs/enrichLeadJob.ts ·
jobs/assignLeadJob.ts ·
controllers/leadEnricherController.ts ·
controllers/leadAssignerController.ts
Before — jobs importan controllers (inversión rara)
BullMQ Worker
enrichLeadJob → leadEnricherController
assignLeadJob → leadAssignerController
HTTP handlers (Express)
Jobs conocen controllers HTTP — acoplamiento invertido
After — jobs como adapters del dominio
BullMQ Worker
EnrichLeadJobAdapter
LeadIntake.process()
Jobs = adapters de transporte. Dominio agnóstico del queue.
Problema: los jobs de BullMQ importan los controllers de Express, creando una dependencia donde la capa de transporte async conoce la capa de transporte sync. El dominio no existe como seam independiente.
Solución: si se implementa el candidato 1, los jobs se convierten automáticamente en adapters de LeadIntake.process() — resuelto como efecto secundario.
- jobs y controllers como adapters paralelos del mismo seam
- dominio testeable sin BullMQ ni Express
- locality: lógica de retry/reintentos aislada en el job-adapter
5 — Colapsar LeadInput / Lead / LeadScore en un modelo de dominio único
Speculative
in-process
Archivos involucrados
types/Lead.ts ·
types/LeadInput.ts ·
types/LeadScore.ts
Before — 3 tipos anémicos
type LeadInput = Omit<Lead, 'id'|'score'|'assignedTo'>
type Lead = { id, email, ..., score, assignedTo }
type LeadScore = { value, breakdown }
Tipos estructurales — no expresan invariantes de dominio
After — Lead como módulo de dominio
Lead (clase de dominio)
static create(input) · scored(score) · assign(agent)
Invariantes: email válido, score [0-100], assignedTo solo si scored
Especulativo — beneficio depende del resto de candidatos
Problema: los tres tipos son estructurales y anémicos — no imponen ningún invariante. Un Lead puede tener assignedTo sin score, o score fuera de rango.
Solución: convertir Lead en una clase de dominio con factory methods que encapsulen la transición de estado; hacer shallow LeadInput y eliminar LeadScore como tipo separado.
- invariantes de dominio concentrados en un único módulo
- locality: bugs de estado se detectan en Lead, no dispersos
Candidato especulativo — el beneficio solo es real si se implementa el candidato 1 primero. Sin un LeadIntake profundo, este cambio introduce complejidad sin leverage real.