● IA-Ingeniería-MLOps ✓ TypeScript ≡ Ports & Adapters ⚡ Avanzado

ContextAI — Arquitectura Hexagonal

Implementación del caso de uso GenerarBorrador con dominio limpio, puertos definidos y adaptadores intercambiables.

Cliente: ContextAI SaaS
Stack: TypeScript · Supabase · Anthropic · Redis · Resend
Problema: 14 archivos a tocar al cambiar LLM provider
Resultado: 1 adaptador, 0 cambios en dominio
Diagrama de capas
Entrada
HTTP Client
POST /api/drafts
Bearer token
req
Adaptador Entrada
DraftController
Express route
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
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
domain/Borrador.ts
ports/outbound
GenerarBorradorUseCase.ts
AnthropicLLMGateway.ts
draftsContainer.ts
// 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
Puerto Tipo Capacidad Adaptador real (prod) Adaptador fake (tests)
WorkspaceRepositoryPort outbound findById(workspaceId) SupabaseWorkspaceRepository InMemoryWorkspaceRepo
DraftRepositoryPort outbound save(borrador) · findById(id) SupabaseDraftRepository InMemoryDraftRepository
LLMGatewayPort outbound generate({prompt, system, maxTokens}) AnthropicLLMGateway InMemoryLLMGateway
EventBusPort outbound publish(DomainEvent) BullEventBus (Redis) InMemoryEventBus
GenerarBorradorPort inbound execute(GenerarBorradorInput) GenerarBorradorUseCase
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 container


UseCase 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