● 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