Diagrama de capas
Entrada
HTTP Client
POST /api/drafts
Bearer token
Bearer token
→
req
Adaptador Entrada
DraftController
Express route
DTO mapping
Auth guard
DTO mapping
Auth guard
→
calls
Caso de Uso (núcleo)
GenerarBorradorUseCase
Valida créditos → LLM → Save → Event
→
via ports
Adaptador Salida
SupabaseDraftRepo
PostgreSQL
Adaptador Salida
AnthropicLLM
Claude API
Adaptador Salida
RedisEventBus
Bull Queue
↕ usa
Dominio puro
Borrador · Workspace
Entidades · Value Objects · Reglas de negocio
Sin imports de Express, Supabase, Anthropic
Sin imports de Express, Supabase, Anthropic
Estructura de módulos
src/features/drafts/
├── domain/
│ ├── Borrador.ts ← Entidad con reglas puras
│ ├── Workspace.ts ← Aggregate con créditos
│ └── BorradorErrors.ts ← DomainErrors tipados
│
├── application/
│ ├── ports/
│ │ ├── inbound/
│ │ │ └── GenerarBorradorPort.ts ← interface de entrada
│ │ └── outbound/
│ │ ├── DraftRepositoryPort.ts ← save/findById
│ │ ├── LLMGatewayPort.ts ← generar(prompt)
│ │ ├── EventBusPort.ts ← publish(event)
│ │ └── WorkspaceRepositoryPort.ts
│ └── use-cases/
│ └── GenerarBorradorUseCase.ts ← orquesta, sin I/O directo
│
├── adapters/
│ ├── inbound/http/
│ │ └── DraftController.ts ← Express route handler
│ └── outbound/
│ ├── SupabaseDraftRepository.ts
│ ├── AnthropicLLMGateway.ts ← antes OpenAI; 1 archivo
│ ├── RedisEventBus.ts
│ └── SupabaseWorkspaceRepository.ts
│
├── composition/
│ └── draftsContainer.ts ← único punto de wiring
│
└── __tests__/
├── GenerarBorradorUseCase.test.ts ← fakes en memoria
├── DraftController.test.ts
└── fakes/
├── InMemoryDraftRepository.ts
├── InMemoryLLMGateway.ts
└── InMemoryEventBus.ts
Código implementado
// src/features/drafts/domain/Borrador.ts // Zero imports from Express, Supabase, Anthropic — pure domain logic import { BorradorCreationError, CreditosInsuficientesError } from './BorradorErrors'; export type BorradorStatus = 'pending' | 'ready' | 'failed'; export interface BorradorProps { id: string; workspaceId: string; brief: string; contenido: string | null; status: BorradorStatus; tokens_used: number; createdAt: Date; } export class Borrador { private constructor(private readonly props: BorradorProps) {} /** Factory: valida invariantes en creación */ static create(input: Omit<BorradorProps, 'status' | 'contenido' | 'tokens_used' | 'createdAt'>): Borrador { if (!input.brief || input.brief.trim().length < 10) { throw new BorradorCreationError('El brief debe tener al menos 10 caracteres'); } return new Borrador({ ...input, status: 'pending', contenido: null, tokens_used: 0, createdAt: new Date() }); } /** Inmutable: devuelve nueva instancia con contenido */ markReady(contenido: string, tokens_used: number): Borrador { return new Borrador({ ...this.props, contenido, tokens_used, status: 'ready' }); } markFailed(): Borrador { return new Borrador({ ...this.props, status: 'failed' }); } get id() { return this.props.id; } get brief() { return this.props.brief; } get contenido() { return this.props.contenido; } get status() { return this.props.status; } get tokens_used() { return this.props.tokens_used; } get workspaceId() { return this.props.workspaceId; } }
// src/features/drafts/application/ports/outbound/LLMGatewayPort.ts export interface LLMGenerateInput { systemPrompt: string; userPrompt: string; maxTokens?: number; } export interface LLMGenerateOutput { contenido: string; tokens_used: number; model: string; } /** Puerto: describe CAPACIDAD, no tecnología */ export interface LLMGatewayPort { generate(input: LLMGenerateInput): Promise<LLMGenerateOutput>; } // ───────────────────────────────────────────────────────────── // src/features/drafts/application/ports/outbound/DraftRepositoryPort.ts import { Borrador } from '../../domain/Borrador'; export interface DraftRepositoryPort { save(borrador: Borrador): Promise<void>; findById(id: string): Promise<Borrador | null>; } // ───────────────────────────────────────────────────────────── // src/features/drafts/application/ports/outbound/EventBusPort.ts export interface DomainEvent { type: string; payload: Record<string, unknown>; occurredAt: Date; } export interface EventBusPort { publish(event: DomainEvent): Promise<void>; } // ───────────────────────────────────────────────────────────── // src/features/drafts/application/ports/outbound/WorkspaceRepositoryPort.ts import { Workspace } from '../../domain/Workspace'; export interface WorkspaceRepositoryPort { findById(workspaceId: string): Promise<Workspace | null>; }
// src/features/drafts/application/use-cases/GenerarBorradorUseCase.ts // Orquesta SOLO lógica de aplicación. Cero imports de Express/Supabase/Anthropic. import { Borrador } from '../domain/Borrador'; import { CreditosInsuficientesError } from '../domain/BorradorErrors'; import { DraftRepositoryPort } from './ports/outbound/DraftRepositoryPort'; import { LLMGatewayPort } from './ports/outbound/LLMGatewayPort'; import { EventBusPort } from './ports/outbound/EventBusPort'; import { WorkspaceRepositoryPort } from './ports/outbound/WorkspaceRepositoryPort'; export interface GenerarBorradorInput { draftId: string; workspaceId: string; brief: string; userId: string; } export interface GenerarBorradorOutput { draftId: string; contenido: string; tokens_used: number; status: string; } export class GenerarBorradorUseCase { constructor( private readonly workspaceRepo: WorkspaceRepositoryPort, private readonly draftRepo: DraftRepositoryPort, private readonly llmGateway: LLMGatewayPort, private readonly eventBus: EventBusPort, ) {} async execute(input: GenerarBorradorInput): Promise<GenerarBorradorOutput> { // 1. Cargar workspace y validar créditos (regla de negocio) const workspace = await this.workspaceRepo.findById(input.workspaceId); if (!workspace) throw new Error(`Workspace ${input.workspaceId} not found`); if (!workspace.hasCredits()) { throw new CreditosInsuficientesError(workspace.id); } // 2. Crear entidad borrador en estado pending let borrador = Borrador.create({ id: input.draftId, workspaceId: input.workspaceId, brief: input.brief }); await this.draftRepo.save(borrador); // 3. Llamar al LLM (sin saber si es Anthropic u OpenAI) const llmResult = await this.llmGateway.generate({ systemPrompt: 'Eres un experto copywriter de marketing. Responde en español.', userPrompt: input.brief, maxTokens: 2048, }); // 4. Actualizar entidad (inmutable → nueva instancia) borrador = borrador.markReady(llmResult.contenido, llmResult.tokens_used); await this.draftRepo.save(borrador); // 5. Emitir evento de dominio await this.eventBus.publish({ type: 'draft.created', payload: { draftId: borrador.id, workspaceId: borrador.workspaceId, tokens_used: borrador.tokens_used }, occurredAt: new Date(), }); return { draftId: borrador.id, contenido: borrador.contenido!, tokens_used: borrador.tokens_used, status: borrador.status, }; } }
// src/features/drafts/adapters/outbound/AnthropicLLMGateway.ts // Antes era OpenAILLMGateway.ts — cambio de provider en UN archivo import Anthropic from '@anthropic-ai/sdk'; import { LLMGatewayPort, LLMGenerateInput, LLMGenerateOutput } from '../../application/ports/outbound/LLMGatewayPort'; export class AnthropicLLMGateway implements LLMGatewayPort { private readonly client: Anthropic; constructor(apiKey: string) { this.client = new Anthropic({ apiKey }); } async generate(input: LLMGenerateInput): Promise<LLMGenerateOutput> { const response = await this.client.messages.create({ model: 'claude-3-5-sonnet-20241022', max_tokens: input.maxTokens ?? 2048, system: input.systemPrompt, messages: [{ role: 'user', content: input.userPrompt }], }); const textBlock = response.content.find(b => b.type === 'text'); if (!textBlock || textBlock.type !== 'text') { throw new Error('LLM returned no text block'); } return { contenido: textBlock.text, tokens_used: response.usage.input_tokens + response.usage.output_tokens, model: response.model, }; } } // ───────────────────────────────────────────────────────────── // tests/fakes/InMemoryLLMGateway.ts ← úsalo en unit tests import { LLMGatewayPort, LLMGenerateInput, LLMGenerateOutput } from '...'; export class InMemoryLLMGateway implements LLMGatewayPort { public calls: LLMGenerateInput[] = []; public response: Partial<LLMGenerateOutput> = {}; async generate(input: LLMGenerateInput): Promise<LLMGenerateOutput> { this.calls.push(input); return { contenido: this.response.contenido ?? '[Borrador generado en test]', tokens_used: this.response.tokens_used ?? 42, model: 'fake-model', }; } }
// src/features/drafts/composition/draftsContainer.ts // Único punto de wiring — aquí viven los new. Nada más sabe de infraestructura. import { SupabaseClient } from '@supabase/supabase-js'; import { Queue } from 'bull'; import { GenerarBorradorUseCase } from '../application/use-cases/GenerarBorradorUseCase'; import { SupabaseDraftRepository } from '../adapters/outbound/SupabaseDraftRepository'; import { SupabaseWorkspaceRepository } from '../adapters/outbound/SupabaseWorkspaceRepository'; import { AnthropicLLMGateway } from '../adapters/outbound/AnthropicLLMGateway'; import { BullEventBus } from '../adapters/outbound/RedisEventBus'; interface DraftsDeps { supabase: SupabaseClient; queue: Queue; anthropicApiKey: string; } export const buildDraftsContainer = (deps: DraftsDeps) => { // Instanciar adaptadores concretos const workspaceRepo = new SupabaseWorkspaceRepository(deps.supabase); const draftRepo = new SupabaseDraftRepository(deps.supabase); const llmGateway = new AnthropicLLMGateway(deps.anthropicApiKey); // ← swap aquí const eventBus = new BullEventBus(deps.queue); // Inyectar en el use case (dominio no sabe de adaptadores) const generarBorradorUseCase = new GenerarBorradorUseCase( workspaceRepo, draftRepo, llmGateway, eventBus ); return { generarBorradorUseCase }; }; // src/app.ts — punto de entrada export const container = buildDraftsContainer({ supabase: createSupabaseClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!), queue: new Queue('events', process.env.REDIS_URL!), anthropicApiKey: process.env.ANTHROPIC_API_KEY!, });
Matriz de puertos y adaptadores
Suite de tests unitarios
GenerarBorradorUseCase
✓ genera borrador correctamente con créditos disponibles (8ms)
✓ guarda el borrador en pending antes de llamar al LLM (2ms)
✓ actualiza el borrador a 'ready' tras respuesta del LLM (3ms)
✓ emite evento draft.created con draftId y tokens_used (2ms)
✓ lanza CreditosInsuficientesError si workspace.hasCredits() es false (1ms)
✓ lanza BorradorCreationError si brief tiene menos de 10 chars (1ms)
✓ no llama al LLM si la validación de créditos falla (1ms)
✓ no modifica el workspace repo (solo lee, no escribe) (1ms)
Borrador (domain entity)
✓ Borrador.create() lanza error si brief < 10 chars
✓ markReady() devuelve nueva instancia inmutable con status 'ready'
✓ markFailed() devuelve nueva instancia con status 'failed'
✓ estado inicial siempre es 'pending' con contenido null
DraftController (inbound adapter)
✓ extrae draftId, workspaceId y brief del body HTTP
✓ devuelve 201 con draftId y contenido en respuesta
✓ mapea CreditosInsuficientesError → HTTP 402
✓ mapea BorradorCreationError → HTTP 400
✓ Tests: 16 passed · 0 failed · 0 skipped
Time: 0.148s (sin red, sin BD, sin LLM — puro in-memory)
Coverage: use-case: 100% · domain: 100% · adapters: skipped (integration)
Anti-patterns evitados vs. código resultante
Antes — acoplado
router.post('/drafts', async (req, res) => {
const resp = await openai.chat.completions.create(...);
await db.from('drafts').insert({...});
res.json(resp);
})14 archivos con imports de openai. Cambiar provider = auditoría completa.
Ahora — desacoplado
// AnthropicLLMGateway.ts implementa LLMGatewayPort
// Use case recibe puerto, no SDK
// Cambiar LLM = cambiar 1 adaptador + 1 línea en containerUseCase no sabe que existe Anthropic. Tests unitarios en <5ms sin API.
Antes — req/res en dominio
async function createDraft(req: Request, res: Response) {
const { brief } = req.body; // Express en lógica
}Testear el dominio requiere mockear Express. Imposible reusar desde CLI o worker.
Ahora — DTO puro en use case
// DraftController extrae de req y pasa DTO limpio
const input: GenerarBorradorInput = {
brief: req.body.brief, workspaceId: req.user.workspaceId, ...
};
await useCase.execute(input);UseCase reutilizable desde HTTP, CLI, Bull worker o cron sin cambios.
Roadmap de migración (strangler approach)
01
Slice piloto
Elegir
GenerarBorrador como primer slice: alto churn, bajo blast-radius. Añadir tests de caracterización antes de tocar nada.02
Extraer puertos
Envolver los
openai, supabase y bull existentes en interfaces de puerto. El código original sigue funcionando como adaptador temporal.03
Mover orquestación
Trasladar la lógica del controller al
GenerarBorradorUseCase. Controller solo mapea HTTP ↔ DTO. Tests pasan sin cambios.04
Swap Anthropic
Crear
AnthropicLLMGateway, sustituir en draftsContainer.ts. Un archivo. Todos los tests siguen verdes. Repetir por feature.Best practices checklist — ContextAI
Dominio y use case importan SOLO tipos internos y ports (no Express, Supabase, Anthropic)
Cada dependencia externa representada como OutboundPort (LLM, DB, EventBus, Workspace)
Validación en boundaries: inbound adapter + invariantes de dominio en Borrador.create()
Transformaciones inmutables: markReady/markFailed devuelven nueva instancia sin mutar estado
Errores traducidos: infra error → ApplicationError → HTTP status (404/402/400) en controller
Composition root explícito y auditable: draftsContainer.ts con un solo buildDraftsContainer()
Use cases testeables con InMemory fakes: 16 tests, 0ms de red, sin API keys en CI
Migración strangler slice a slice: código legacy coexiste hasta que el slice es estable en prod