Domain-Driven Design: Anti-Corruption Layer, cómo proteger el dominio de modelos y sistemas externos.
Construye una ACL para integrar un proveedor de pagos sin filtrar sus estados al dominio. Define un contrato interno, valida respuestas, traduce identidades y significados y distingue errores técnicos de resultados del negocio con TypeScript.
Cuando «aprobado» empieza a decidir por tu negocio
Payments integra una pasarela externa. Una respuesta incluye transaction_id y un estado APPROVED. Para avanzar rápido, esos campos aparecen en el caso de uso, en el modelo del pago y en las respuestas de la aplicación.
Meses después llega otro proveedor. También usa la palabra «aprobado», pero para una autorización que todavía no ha sido capturada. El código compila y, sin embargo, la aplicación podría tratar como confirmado un importe que aún no ha cobrado.
El problema no es el idioma de los identificadores. Es que una interpretación externa ha pasado a decidir qué significa un pago dentro de Payments.
Una Anti-Corruption Layer, o ACL, permite integrar esa funcionalidad expresándola en términos del modelo consumidor. Vamos a construir una lectura de resultados con contrato interno, validación, traducción y un adaptador HTTP. No vamos a ejecutar cobros ni a implementar una plataforma financiera completa.
Qué protege una Anti-Corruption Layer
La ACL mantiene una frontera de interpretación entre el contexto consumidor y un modelo externo. El dominio utiliza conceptos propios; la integración se encarga de entender el contrato ajeno y traducirlo.
«Corrupción» aquí significa que el modelo local pierde coherencia al incorporar conceptos y supuestos que no le corresponden. No implica que el proveedor sea defectuoso, malicioso o antiguo.
La ACL puede proteger una integración con una pasarela moderna, otro contexto de la misma organización o un sistema legado imprescindible.
Microsoft también describe su aplicación cuando dos subsistemas no comparten semántica. La capa puede implementarse mediante adaptadores o fachadas; no necesita convertirse en un servicio desplegado por separado. Patrón Anti-Corruption Layer.
El límite en nuestro ejemplo
Aplicación Payments
|
| PaymentResults: lenguaje del consumidor
v
ACL dentro de Payments
- adaptador HTTP
- validación del contrato externo
- traducción de estados e identidad
|
| transaction_id, APPROVED, DECLINED, IN_REVIEW
v
Proveedor de pagos externo
Las flechas representan colaboración. Las dependencias del modelo interno no apuntan a los DTOs del proveedor.
La ACL pertenece al consumidor que necesita la protección. El proveedor no tiene que cambiar su API para que podamos introducirla.
El caso de uso conoce el código de la pasarela. No comprueba a qué pago corresponde la transacción ni bajo qué condiciones APPROVED permite confirmar.
Agregar as Payment a una respuesta JSON no lo corrige. Tampoco basta con renombrar transaction_id como paymentId: podrían ser identidades diferentes.
Primero hay que definir qué necesita afirmar Payments.
Acordar equivalencias antes de programarlas
En este caso hipotético, el proveedor documenta una consulta de operaciones de captura. En esa API concreta:
APPROVED significa que se completó la captura del importe indicado.
DECLINED significa que ese intento fue rechazado.
IN_REVIEW significa que el proveedor todavía está revisando el resultado.
La respuesta incluye referencia del comercio, importe y moneda.
Un error HTTP o de red no es un rechazo financiero.
Payments utiliza CONFIRMED, REJECTED y PENDING_REVIEW para representar esos resultados de consulta.
La equivalencia APPROVED → CONFIRMED depende de ese acuerdo específico. No es una regla general sobre pasarelas. Si otro proveedor usa APPROVED para autorización, hará falta otra interpretación y posiblemente más información.
Además, REJECTED significa que el intento consultado fue rechazado, no que una persona nunca pueda pagar ni que cualquier otro intento haya fallado.
Una ACL no puede convertir información insuficiente en certeza. Si el proveedor no permite distinguir autorización de captura, hay que obtener esa evidencia o mantener la operación sin confirmar.
Un contrato interno que expresa la necesidad
Payments define estos tipos en application/payment-results.ts:
PaymentId es un alias didáctico. No aporta identidad nominal ni valida una cadena por sí solo; en un proyecto real puede ser un tipo más estricto u objeto de valor.
El contrato no menciona HTTP ni transaction_id. Payments pregunta por su pago y recibe una observación en su lenguaje.
La identidad de la transacción remota se resuelve mediante una referencia que la integración obtuvo anteriormente. No debe confundirse con el identificador local.
No hace falta crear una clase por propiedad. El criterio es localizar lo específico del proveedor y evitar que llegue a la lógica consumidora.
El cliente HTTP usa el contrato interno; la aplicación no importa el DTO externo. Esta organización es ilustrativa y no pretende describir archivos existentes en nuestro proyecto.
Validar la estructura de la respuesta
El proveedor devuelve una representación ficticia como esta:
En provider-contract.ts localizamos su estructura y validación:
exportinterfaceProviderCapture {
readonlytransaction_id: string;
readonlymerchant_reference: string;
readonlystatus: "APPROVED" | "DECLINED" | "IN_REVIEW";
readonlyamount_minor: number;
readonlycurrency_code: "EUR";
}
exportclassProviderContractErrorextendsError {}
functionisRecord(value: unknown): value is Record<string, unknown> {
returntypeof value === "object" && value !== null &&
!Array.isArray(value);
}
exportfunctionparseCapture(raw: unknown): ProviderCapture {
if (!isRecord(raw)) {
thrownewProviderContractError("Respuesta de captura inválida");
}
if (
typeof raw.transaction_id !== "string" ||
!/^[A-Za-z0-9_-]{1,64}$/.test(raw.transaction_id) ||
typeof raw.merchant_reference !== "string" ||
!/^[A-Za-z0-9_-]{1,64}$/.test(raw.merchant_reference)
) {
thrownewProviderContractError("Referencias de captura inválidas");
}
const status = raw.status;
if (
status !== "APPROVED" &&
status !== "DECLINED" &&
status !== "IN_REVIEW"
) {
thrownewProviderContractError("Estado de captura no soportado");
}
const amount = raw.amount_minor;
if (
typeof amount !== "number" ||
!Number.isSafeInteger(amount) ||
amount <= 0 ||
raw.currency_code !== "EUR"
) {
thrownewProviderContractError("Importe o moneda inválidos");
}
return {
transaction_id: raw.transaction_id,
merchant_reference: raw.merchant_reference,
status,
amount_minor: amount,
currency_code: "EUR",
};
}
El parser construye un objeto nuevo con los campos conocidos. Las propiedades adicionales no se trasladan al interior.
Las restricciones de identificadores y la moneda única son supuestos explícitos del ejemplo. Un proveedor real puede requerir otros formatos. No copies estos límites sin contrastarlos con su documentación.
Hasta aquí hemos comprobado forma y valores permitidos. Todavía falta comprobar identidad, importe esperado y significado del resultado.
Traducir el significado y comprobar la correspondencia
En translate-capture.ts:
importtype {
PaymentExpectation,
PaymentObservation,
PaymentState,
} from"../../application/payment-results.js";
import {
ProviderContractError,
typeProviderCapture,
} from"./provider-contract.js";
exportfunctiontranslateCapture(capture: ProviderCapture,
expected: PaymentExpectation,
requestedTransactionId: string,
): PaymentObservation {
if (
capture.transaction_id !== requestedTransactionId ||
capture.merchant_reference !== expected.paymentId ||
capture.amount_minor !== expected.amountMinor ||
capture.currency_code !== expected.currency
) {
thrownewProviderContractError("La captura no corresponde al pago");
}
letstate: PaymentState;
switch (capture.status) {
case"APPROVED":
state = "CONFIRMED";
break;
case"DECLINED":
state = "REJECTED";
break;
case"IN_REVIEW":
state = "PENDING_REVIEW";
break;
default:
thrownewProviderContractError("Estado de captura no soportado");
}
return { paymentId: expected.paymentId, state };
}
La traducción estructural cambia merchant_reference por una referencia local. La traducción semántica determina qué observación permite afirmar cada estado según el acuerdo anterior.
El identificador local viene de Payments. Solo lo devolvemos después de contrastar que la respuesta corresponde a ese pago y a la transacción solicitada.
Si el proveedor admite capturas parciales o varias monedas, esta traducción sería insuficiente. No hay que forzar esos casos dentro de una equivalencia diseñada para captura completa en EUR.
Conectar la lectura mediante un adaptador HTTP
El adaptador necesita localizar la referencia remota. La abstracción siguiente no carga el agregado de otro contexto; resuelve una relación de identidades administrada por la integración.
Los fragmentos presuponen Node.js con fetch y AbortSignal.timeout, así como compilación de TypeScript con los tipos correspondientes. La ruta es ficticia y no debe utilizarse para consultar una pasarela real.
El origen debe provenir de configuración controlada para un proveedor autorizado. HTTPS no permite aceptar destinos arbitrarios desde una petición del usuario.
No mostramos autenticación del proveedor, límites de tamaño de respuesta ni observabilidad completa. Deben concretarse antes de producción. El ejemplo realiza únicamente lectura; no implementa captura, reintentos de cobro ni persistencia de la referencia.
Una respuesta JSON inválida o un incumplimiento del contrato siguen siendo errores. La capa de entrada debe tratarlos explícitamente y registrar lo necesario sin exponer credenciales, datos de tarjeta o cuerpos completos.
Un 404 remoto tampoco se convierte en REJECTED: podría significar una referencia errónea, un recurso no visible o una situación del contrato que debemos investigar.
El caso de uso es pequeño porque solo solicita una observación. Una decisión posterior puede usar CONFIRMED, REJECTED o PENDING_REVIEW sin conocer los códigos de la pasarela.
No hemos persistido un cambio de estado. Una observación no autoriza a sobrescribir indiscriminadamente estados posteriores: devoluciones, anulaciones o información más reciente requieren reglas locales.
Cambiar de proveedor puede exigir otro adaptador y otra resolución de referencias. La ACL reduce la propagación de esos cambios; no promete que sustituir un sistema sea gratuito.
Probar la frontera semántica
Estos ejemplos de pruebas unitarias usan APIs integradas de Node.js y presuponen compilación a JavaScript ESM. No se han ejecutado como parte del artículo.
En test/translate-capture.test.ts:
import test from"node:test";
import assert from"node:assert/strict";
import { parseCapture } from"../infrastructure/capture-provider/provider-contract.js";
import { translateCapture } from"../infrastructure/capture-provider/translate-capture.js";
const expected = {
paymentId: "payment-100",
amountMinor: 7000,
currency: "EUR",
} asconst;
functionresponse(status: string, amount = 7000) {
return {
transaction_id: "tx-900",
merchant_reference: "payment-100",
status,
amount_minor: amount,
currency_code: "EUR",
};
}
test("traduce una captura aprobada según el acuerdo", () => {
const result = translateCapture(
parseCapture(response("APPROVED")),
expected,
"tx-900",
);
assert.deepEqual(result, {
paymentId: "payment-100",
state: "CONFIRMED",
});
});
test("mantiene pendiente una revisión del proveedor", () => {
const result = translateCapture(
parseCapture(response("IN_REVIEW")),
expected,
"tx-900",
);
assert.equal(result.state, "PENDING_REVIEW");
});
test("traduce un rechazo explícito del intento", () => {
const result = translateCapture(
parseCapture(response("DECLINED")),
expected,
"tx-900",
);
assert.equal(result.state, "REJECTED");
});
test("un estado nuevo no se convierte en confirmación", () => {
assert.throws(() =>parseCapture(response("AUTHORIZED")));
});
test("rechaza una captura de otro importe o transacción", () => {
assert.throws(() =>translateCapture(
parseCapture(response("APPROVED", 6000)),
expected,
"tx-900",
));
assert.throws(() =>translateCapture(
parseCapture(response("APPROVED")),
expected,
"tx-other",
));
});
Las pruebas verifican equivalencias y correspondencia bajo los supuestos declarados. No demuestran que un proveedor real conserve esa semántica.
También hacen falta pruebas del adaptador para errores de red, 404, JSON inválido y una referencia local ausente, además de verificaciones contra el contrato real en un entorno de pruebas autorizado.
Si todas las pruebas son mocks inventados por el consumidor, una interpretación equivocada de APPROVED puede seguir apareciendo en verde.
Diferenciar una ACL de piezas técnicas cercanas
Adapter Pattern: adapta una interfaz a otra. Puede ser una pieza de la ACL, pero no expresa por sí solo qué modelo se está protegiendo ni si la traducción semántica es correcta.
Facade: ofrece una entrada simplificada a un subsistema. Puede ocultar complejidad sin impedir que los conceptos externos definan las decisiones locales.
Mapper: transforma datos. Un mapper que solo cambia nombres puede ser suficiente para un formato, pero no resuelve diferencias de identidad, unidades o significado.
Conformist: adopta el modelo externo porque resulta aceptable. La ACL conserva una interpretación propia y traduce la interacción.
Repositorio: normalmente expresa acceso a persistencia del modelo local. Un adaptador que consulta una pasarela está integrando una capacidad externa; llamarlo repositorio no le concede acceso directo al almacenamiento de otro contexto.
Una ACL puede combinar adaptadores, mappers y una fachada. No necesita una clase llamada AntiCorruptionLayer; su valor está en la frontera que mantiene.
Errores y evolución que no deben filtrarse al dominio
Si el proveedor agrega AUTHORIZED, no lo mapees por parecido a CONFIRMED. Revisa la evidencia que representa y qué necesita Payments para decidir.
Si cambia céntimos por una cadena decimal, el parser fallará. La corrección debe aclarar unidades y precisión antes de convertir el dato.
Si IN_REVIEW pasa a significar un resultado final bloqueado, mantener la traducción antigua sería un error semántico aunque no cambie el esquema.
Un timeout indica que no pudimos completar la consulta, no que el pago esté rechazado. La indisponibilidad y el resultado financiero pertenecen a categorías diferentes.
Los eventos externos también pueden necesitar esta protección. Un nombre de evento remoto no debe convertirse automáticamente en un hecho del modelo local sin comprobar identidad y significado. El principio se mantiene aunque no usemos HTTP.
La ACL tampoco crea una transacción compartida. Un fallo al registrar una observación local no revierte una operación financiera remota.
Dos casos adicionales para decidir si aporta valor
Logística: de incidencia externa a decisión de soporte
Un transportista declara DELIVERY_EXCEPTION. Para soporte, ese estado puede justificar una investigación, pero no una indemnización automática.
Una ACL puede traducirlo a «requiere revisión de entrega» y conservar las reglas de compensación en el contexto responsable. El valor está en no transformar un código operativo en una conclusión comercial más fuerte.
Si una página solo reproduce el seguimiento declarado por el transportista, adoptar ese lenguaje puede ser suficiente. La traducción adicional no tiene el mismo beneficio en ambos consumidores.
Educación: proteger un expediente frente a un sistema legado
Un sistema antiguo devuelve una nota 0 tanto para una evaluación no rendida como para una evaluación rendida con cero puntos. El nuevo expediente necesita distinguir ambos casos.
Renombrar el campo no alcanza. La ACL debe obtener información adicional que permita separar los significados. Si el legado no la ofrece, debe señalarse la ambigüedad o acordarse un proceso de revisión.
La lección es que una capa de traducción no recupera hechos que el proveedor no conserva.
Cuándo la protección se convierte en complejidad innecesaria
Una ACL aporta valor si existen diferencias relevantes de conceptos, reglas, identidad o evolución que conviene mantener fuera del modelo propio.
Puede ser excesiva cuando dos interfaces ya expresan exactamente la misma necesidad y una transformación solo duplica nombres sin proteger ninguna decisión.
Empieza con el contrato interno mínimo. Si el equipo crea un modelo genérico capaz de representar todas las pasarelas posibles, puede terminar imponiendo otra complejidad externa al dominio.
Revisa también los puntos de fuga: DTOs del proveedor en firmas públicas, códigos remotos en decisiones locales y acceso directo a la pasarela desde distintos casos de uso. Si esos caminos siguen creciendo, la carpeta de adaptadores no está manteniendo una frontera real.
Pedir a la IA traducciones que puedas justificar
Para Cursor, Claude o Codex, proporciona contrato externo, contrato interno y ejemplos de semántica confirmada.
Para analizar:
Compara esta consulta de capturas con nuestro modelo de Payments. Identifica diferencias de identidad, estados, unidades y errores. Distingue equivalencias documentadas de hipótesis. No asumas que APPROVED significa captura completada sin evidencia.
Para implementar:
Implementa un puerto interno de observación y una ACL HTTP propiedad de Payments. Valida JSON unknown, referencia remota, referencia local, importe y moneda. Mantén DTOs y códigos del proveedor dentro del adaptador. Un timeout o 404 no representa un rechazo financiero.
Para revisar:
Revisa este diff buscando términos externos fuera de la ACL, casts que sustituyen validación y estados nuevos tratados como confirmados. Explica el comportamiento afectado y las pruebas necesarias. No propongas ejecutar cobros reales ni introducir infraestructura sin necesidad.
La persona responsable debe validar las equivalencias con el negocio y la documentación del proveedor. Compilar no convierte una similitud lingüística en una garantía financiera.
Ejercicios
1. Aprobado sin captura
Otra pasarela usa APPROVED para indicar autorización. ¿Puedes reutilizar la traducción anterior cambiando la URL?
2. La referencia que parece un PaymentId
El proveedor cambia transaction_id en cada reintento. El equipo quiere usarlo como identificador del pago local. ¿Qué revisarías?
3. El timeout convertido en rechazo
Una captura tarda demasiado y la aplicación marca el pago como REJECTED. ¿Qué debería cambiar?
Soluciones razonadas
1. No. La equivalencia depende del significado de la operación, no del nombre del estado. Hay que obtener evidencia de captura o conservar una observación que no confirme todavía. El mismo contrato interno solo sirve si el nuevo proveedor puede satisfacerlo correctamente.
2. Revisaría la identidad del pago local, la de cada intento y las referencias externas. No son intercambiables por comodidad. La integración debe conservar su correspondencia sin redefinir el identificador local según los reintentos del proveedor.
3. Separar fallo de comunicación de rechazo financiero. La consulta debe producir un error técnico o una situación de comprobación pendiente según el proceso acordado, sin inventar que el proveedor rechazó la operación.
Reconocer una ACL útil
La protección funciona cuando puedes cambiar una representación externa sin enseñar sus detalles a todos los casos de uso, y cuando cada equivalencia importante tiene una justificación verificable.
Comprueba que el contrato interno expresa una necesidad real, que la traducción conserva identidad y significado, y que los errores no se convierten en hechos del negocio.
La ACL no elimina la dependencia del proveedor. Permite mantenerla en un límite explícito donde pueda entenderse, probarse y evolucionar sin redefinir accidentalmente el dominio.
Diseña una interfaz común de Inventory para Sales, Shipping y Marketplace. Define disponibilidad y reservas con TypeScript y Express, valida entradas y errores y planifica pruebas y evolución sin exponer el modelo interno.
Aprende a coordinar Sales y Payments cuando su entrega depende del éxito de ambos. Define responsabilidades y contratos, diseña pruebas conjuntas y planifica cambios compatibles sin fusionar modelos ni asumir transacciones compartidas.
Aprende cuándo adoptar el modelo de un proveedor sin influencia sobre sus decisiones. Explora un catálogo externo con TypeScript, validación y errores, reconoce el acoplamiento semántico y decide cuándo una ACL aporta autonomía.