Files
sf-sim/.agents/skills/sf-backend-architecture/references/ANTI-PATTERNS.md
Jorge 9a5308c3c9 chore(claude): configurar Claude Code y formalizar convenciones
Hasta ahora el proyecto carecía de convenciones documentadas. Esta
configuración inicial consolida code style, git conventions, política
de tests, comandos /audit y /check, y las skills locales del repo
(sf-backend-architecture y clean-ddd-hexagonal) en una estructura
reutilizable: defaults estrictos al copiar a otros repos, con
excepciones específicas de sf-sim documentadas por su carácter legacy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:09:18 +02:00

14 KiB

Anti-patrones — sf-sim

Lista de olores arquitectónicos a detectar. Algunos están vivos en el repo y se citan como ejemplo. Para cada uno: por qué es un problema, cómo se ve, cómo se arregla.

Tabla de contenidos

  1. Capas y dependencias
  2. DDD táctico
  3. Hexagonal / ports & adapters
  4. EDA / RabbitMQ
  5. CQRS
  6. Persistencia
  7. Manejo de errores
  8. Tests

Capas y dependencias

Fuga de infraestructura al usecase

Problema: un caso de uso importa una clase concreta de infrastructure/ en vez del port. Se rompe la regla de dependencias hacia dentro y el usecase se vuelve no testeable sin la infra.

Ejemplo en el repo: packages/sim-entrada-eventos/aplication/Sim.usecases.ts:1 importa OrderRepository concreto de sim-shared/infrastructure/. Debería importar un OrderRepository.port.

Cómo se ve:

// ❌ Mal
import { OrderRepository } from "sim-shared/infrastructure/OrderRepository.js";

export class SimUsecases {
  constructor(args: { orderRepository: OrderRepository }) { /* ... */ }
}

Arreglo: define el port en sim-shared/domain/:

// sim-shared/domain/OrderRepository.port.ts
export interface OrderRepositoryPort {
  createOrder<T>(data: CreateOrderDTO): Promise<Result<string, OrderTracking<T>>>;
  // ...
}

Y úsalo:

// ✅ Bien
import { OrderRepositoryPort } from "sim-shared/domain/OrderRepository.port.js";

export class SimUsecases {
  constructor(args: { orderRepository: OrderRepositoryPort }) { /* ... */ }
}

El OrderRepository concreto (en sim-shared/infrastructure/) implementa el port. El cableado en config/ pasa la implementación concreta al constructor.

Domain importa I/O

Problema: domain/ importa pg, amqplib, axios, express, etc.

Por qué importa: el dominio debería poder ejecutarse en un test sin levantar infra. Si importa I/O, ya no puede.

Arreglo: mueve cualquier I/O a un adapter en infrastructure/ y expón un port que el dominio use.

Cruce de capa con import relativo largo

Ejemplo: aplication/X.ts que importa ../infrastructure/Y.ts. Aunque "compila", está saltándose el port y acoplándose al adapter concreto.

Arreglo: usa el port. Si no existe, crea uno.


DDD táctico

Modelo anémico

Problema: entidades y value objects son "data bags" sin comportamiento. Toda la lógica vive en services o usecases.

Cómo se ve:

// ❌ Anémico
class Order {
  id: number;
  status: OrderStatus;
  // sólo getters/setters
}

// La lógica está afuera
function canCancel(order: Order): boolean { /* ... */ }
function cancel(order: Order): void { order.status = 'cancelled'; }

Arreglo:

// ✅ Con comportamiento
class Order {
  private constructor(private id: number, private status: OrderStatus) {}

  cancel(): Result<string, void> {
    if (this.status === 'finished') return { error: "no se puede cancelar una order finalizada" };
    this.status = 'cancelled';
    return { data: undefined };
  }
}

Cuándo es OK ser anémico: los DTOs de comando/query y los tipos puros de transferencia. Pero los agregados deberían tener invariantes y comportamiento.

Excepción para flujo de negocio

Problema: lanzar Error para un caso esperado en vez de devolver Result.

Ejemplo en el repo: domain/companies.ts:companyFromIccid lanza si la compañía no existe. Eso fuerza try/catch en el controller y rompe el patrón Result.

Arreglo:

// ✅
export function companyFromIccid(iccid: string): Result<string, string> {
  const code = iccid.slice(2, 6);
  const company = COMPANYICCID.get(code);
  if (company == undefined) return { error: `Compañía desconocida: ${code}` };
  return { data: company };
}

throw se reserva para invariantes rotas (bugs).

Primitive obsession

Problema: representar dominio con tipos primitivos (string, number) en vez de Value Objects.

Cómo se ve: iccid: string por todos lados, sin garantía de que sea un ICCID válido.

Arreglo: crea un VO Iccid con factory que valida. Tradeoff: más boilerplate, pero el dominio gana garantías. Hazlo cuando la validación se repite o cuando el tipo se confunde con otros strings.

Repository por entidad

Problema: una entidad-hijo dentro de un agregado tiene su propio repository, saltándose la raíz.

Arreglo: un repositorio por agregado, no por tabla. Operaciones a entidades-hijo van por la raíz.


Hexagonal / ports & adapters

Port en infrastructure/

Problema: la interfaz vive en infra. Los usecases tienen que importar de infra para conocer el contrato.

Arreglo: ports SIEMPRE en domain/ (o sim-shared/domain/). Adapter en infrastructure/.

Adapter sin port

Problema: una clase adapter sin interfaz declarada. Los usecases dependen directamente de la clase.

Arreglo: extrae la interfaz y haz que el usecase dependa de ella. Aunque sólo haya un adapter hoy, el port permite testear con mocks.

new fuera del composition root

Problema: un usecase, controller o adapter hace new OtraClase(...) para resolver una dependencia.

Arreglo: todo cableado vive en config/ o index.ts. Pasa la dependencia ya construida al constructor.


EDA / RabbitMQ

Publish que no espera confirmación real del broker

Problema: se llama channel.publish(...) y se asume éxito sin esperar el callback de confirm. El Promise<{ success, error }> que devuelve la implementación miente: marca success aunque el broker no haya aceptado el mensaje. Cualquier diseño de consistencia (outbox, retry, etc.) cae cuando la señal de "publicado" no es fiable.

Cómo detectarlo: revisa el publish del bus shared. Si después del channel?.publish(...) se hace successEvents.push(event) síncrono, sin esperar callback ni waitForConfirms(), hay bug.

Ejemplo en el repo: sim-shared/infrastructure/RabbitMQEventBus.ts — el canal se crea con confirm: true pero en publish se pushea a successEvents justo después de llamar a channel.publish, sin esperar el callback (err, ok) => .... El bug es independiente del orden publish/save y enmascara cualquier solución de outbox: si el broker rechaza el mensaje, el Promise resolverá éxito igualmente.

Arreglo:

// ✅ Esperar la confirmación de verdad
async publish(events: DomainEvent[]) {
  const successEvents: DomainEvent[] = [];
  const errorEvents: DomainEvent[] = [];
  for (const ev of events) {
    try {
      await new Promise<void>((res, rej) => {
        this.channel.publish(exchange, ev.key, content, opts, (err, ok) => {
          if (err) rej(err);
          else res();
        });
      });
      successEvents.push(ev);
    } catch (e) {
      errorEvents.push(ev);
    }
  }
  return { success: successEvents, error: errorEvents };
}

O usar waitForConfirms() del canal tras los publishes. La librería amqp-connection-manager tiene ChannelWrapper.publish que devuelve una Promise que SÍ espera confirms — usarla es el camino corto.

Cuándo es bloqueante: siempre que un consumer dependa de la señal "publicado" para tomar decisiones (outbox, marcar order como sent, devolver OK al cliente HTTP). El usuario que llamó a /activation recibe 200 cuando el broker quizá rechazó el mensaje.

Publish + save no atómicos

Problema: se publica un evento y luego (o antes) se persiste un registro relacionado. Si una de las dos falla, hay inconsistencia silenciosa.

Ejemplo en el repo: los métodos de SimUsecases (activation, cancelation, etc.) hacen eventBus.publish seguido de saveOrder. Si el save falla, el evento ya está fuera. Si invirtiéramos el orden, podría fallar el publish con la order ya creada.

Arreglo: transactional outbox. Ver EVENTS-RABBITMQ.md#outbox.

Cuándo es bloqueante: cuando perder un evento implica pérdida de dinero o de operaciones que el cliente espera (activaciones, cancelaciones de pago).

Consumer no idempotente

Problema: procesar el mismo mensaje dos veces tiene efectos distintos a procesarlo una.

Cómo se ve: un handler que hace INSERT en BDD sin chequear duplicados, o llama a una API externa sin clave de idempotencia.

Arreglo:

  • Unique constraint en correlation_id.
  • Tabla processed_messages con INSERT antes de procesar.
  • O claves de idempotencia en la API externa (NOS, Objenious).

Routing key fuera de patrón

Problema: publicar con key: "activation_request" o key: "alai.activate" (sin el prefijo sim.).

Arreglo: ajustarse a sim.[compañia].[acción]. Si necesitas otro patrón, justifícalo en el PR.

Message sin message_id

Problema: se publica un evento sin headers.message_id. Pierdes trazabilidad.

Arreglo: SIEMPRE inyecta message_id: uuidv7() antes de publicar.

nack con requeue: true

Problema: reentrega inmediata. Satura el broker y no respeta la política de delay/DLX.

Arreglo: usa el nack de RabbitMQEventBus que republica al delayed exchange con x-retry-count++.

Worker que crea sus propios exchanges

Problema: un worker hace assertExchange("sim.exchange", ...) en su setup. Si las opciones no coinciden con el principal, RMQ falla.

Arreglo: los exchanges principales (sim.exchange, sim.dlx) los crea RabbitMQEventBus.createChannel. El servicio sólo crea SUS colas y bindings.


CQRS

Read y write mezclados en un usecase

Problema: un único XUsecases tiene métodos que escriben (commands) y métodos que leen para devolver datos al cliente (queries). Las queries escalan distinto, los commands tienen invariantes.

Cómo se ve: una clase con createOrder(), cancelOrder(), getOrderById(), searchOrders() mezclados.

Arreglo (cuando duele): divide en XCommandUsecases y XQueryUsecases. Mientras no duela, vivir con la mezcla es razonable; no fuerces CQRS si el read y write usan la misma BDD y los mismos datos.

Read model no sincronizado o sin lag documentado

Problema: introduces un read model (proyección) sin definir cómo se sincroniza ni cuál es el lag aceptable.

Arreglo: documenta:

  • Mecanismo de sincronización (consumer del bus, replica de Postgres, materialized view, ...).
  • Lag esperado y máximo aceptable.
  • Comportamiento ante errores de proyección.

CQRS prematuro

Problema: divides commands/queries en un servicio simple sin necesidad real, multiplicando el boilerplate.

Arreglo: empieza con un usecase mezclado. Divide cuando: (a) las queries necesitan un schema distinto, (b) el read se escala diferente, (c) hay tantas queries que ahogan al XUsecases.


Persistencia

Transacción cruzando agregados

Problema: una sola transacción modifica entidades de dos agregados distintos.

Arreglo: un agregado por transacción. La consistencia entre agregados se gana por eventos (eventual).

Repositorio expone SQL crudo

Problema: el repositorio devuelve QueryResult<Row> o expone métodos genéricos query(sql, params).

Arreglo: el repositorio devuelve entidades de dominio o DTOs de la capa de aplicación. SQL queda dentro.

Falta de rollback en error

Problema: un repositorio hace BEGIN pero no ROLLBACK en algunos paths de error.

Cómo se evita: usa el patrón del repo:

try {
  await client.query("BEGIN");
  // ...
  if (algo.error) {
    await client.query("ROLLBACK");
    return algo;
  }
  await client.query("COMMIT");
} finally {
  client.release();
}

O un wrapper withTransaction(client, async (tx) => { ... }) que lo encapsule.

Cliente PG sin release

Problema: pgClient.connect() devuelve un PoolClient que SIEMPRE debe hacer release(). Si no, fugas de conexión.

Arreglo: try/finally con release() o un wrapper que lo garantice.


Manejo de errores

Mezcla de Result y excepciones en la misma capa

Problema: algunos métodos de un usecase devuelven Result, otros tiran. El llamador no sabe qué hacer.

Arreglo: decide por capa. Aplicación devuelve Result. Dominio devuelve Result para reglas, lanza para invariantes rotas. Infra devuelve Result para errores de I/O esperables, lanza para configuración rota.

error: "string" sin tipar

Problema: se usa Result<string, T> con strings libres. Imposible saber qué errores espera el llamador.

Arreglo: cuando la cantidad de errores se estabiliza, define un tipo unión: Result<"NotFound" | "AlreadyExists" | "InvalidInput", T>. Mientras evoluciona, strings está bien.

data? accedido sin chequear error

Problema:

const r = await usecase.x();
res.json({ id: r.data?.id });  // ❌ ignora el error

Arreglo:

const r = await usecase.x();
if (r.error != undefined) return res.status(500).json({ error: r.error });
res.json({ id: r.data.id });

Tests

Test de aplicación que necesita Postgres

Problema: para testear un usecase tienes que levantar la BDD. Síntoma de que el usecase depende del adapter, no del port.

Arreglo: mockea el port. Si no puedes mockearlo porque no existe, crea el port y refactoriza.

Test de dominio con mocks

Problema: el dominio puro tiene mocks. Eso significa que el dominio depende de algo de fuera. Mira los imports.

Arreglo: el dominio puro no debería necesitar mocks. Si necesitas, mueve la dependencia a un port y testéalo a nivel de aplicación.

Solo hay tests de infra

Problema: todo está testeado integradamente, lento. La regresión de un cambio en aplicación se descubre por integration tests.

Arreglo: invierte la pirámide: muchos unitarios (dominio + aplicación con mocks), pocos integrados (infra crítica).