Módulo 5: Extraer un servicio

Traducir en ambas direcciones

Descripción

En la lección anterior construiste medio ACL: la traducción de ida, legacy → modern, que toma el modelo viejo del monolito y entrega el modelo limpio que el servicio consume. Con esa mitad, el servicio ya recibe Product en vez de diccionarios crípticos. Pero falta la otra mitad, y sin ella la extracción se rompe: cuando el servicio responde, el monolito espera recibir su modelo viejo de vuelta. Si le devuelves un Product, el monolito —que solo sabe leer row["prc_cents"] y row["desc"]— falla. Esta lección construye la traducción de vuelta, modern → legacy, y demuestra que el ACL protege a los dos lados.

La idea es simétrica y hay que verla completa. Una request que sale del monolito y va al servicio cruza el borde dos veces: una al entrar (el ACL traduce el modelo viejo al limpio, para que el servicio lo entienda) y otra al salir (el ACL traduce el modelo limpio de vuelta al viejo, para que el monolito lo entienda). Entre las dos traducciones, el servicio trabaja puramente en su modelo limpio —nunca toca una clave legacy—. Y fuera de las dos traducciones, el monolito trabaja puramente en su modelo viejo —nunca ve un Product—. El ACL es la membrana que mantiene a cada mundo en su idioma.

Esta lección lo ejecuta con un flujo completo trazado: monolito → ACL → servicio → ACL → monolito, imprimiendo qué modelo se ve en cada borde. Y lo verifica con dos contadores que tienen que dar cero: cuántas claves legacy vio el servicio (debe ser 0) y cuántos objetos Product vio el monolito (debe ser 0). Si los dos dan cero, la protección es total en ambos sentidos.

Conexión con el módulo. La lección 3 construyó la dirección de entrada del ACL; esta completa la de salida y muestra las dos trabajando juntas. Con el ACL bidireccional listo, las lecciones 5 y 6 pueden cortar los datos (propiedad y BD) con la seguridad de que el contrato del monolito se mantiene: el ACL de vuelta es lo que garantiza que, pase lo que pase con los datos por dentro, el monolito siga recibiendo su modelo viejo intacto. Fíjate en la frontera: aquí el ACL traduce entre dos modelos en las dos direcciones. Lo que el servicio hace con el modelo limpio por dentro (su lógica de negocio) es del dominio del servicio; el ACL solo garantiza que ese mundo limpio y el mundo viejo del monolito no se mezclen.

Una analogía: el intérprete traduce en las dos direcciones de la mesa

Vuelve a la negociación entre los dos socios con el intérprete en medio. Sería absurdo que el intérprete tradujera solo en una dirección —del inglés al español, pero no del español al inglés—. El socio inglés hablaría, el español entendería, respondería en español... y el inglés se quedaría mirando sin entender la respuesta. La conversación se rompería a la mitad. Un intérprete sirve porque traduce en las dos direcciones de la mesa: cada vez que uno habla, su mensaje llega al otro en el idioma del otro.

Y fíjate en algo que el buen intérprete garantiza: el socio inglés nunca tiene que oír una palabra en español, y el español nunca tiene que oír una palabra en inglés. Cada uno vive toda la negociación en su propio idioma, como si el otro también lo hablara. La rareza del otro —su idioma, sus unidades, sus modismos— nunca cruza la mesa; se queda en el intérprete. Si en algún momento el socio inglés escuchara un "sí" en español, o el español un "yes" en inglés, algo habría fallado en la interpretación: la membrana se rompió.

El ACL bidireccional es exactamente eso. La request del monolito llega al servicio traducida a Product (el intérprete tradujo del "inglés" viejo al "español" limpio). El servicio responde en Product, y el intérprete lo traduce de vuelta al modelo viejo antes de entregárselo al monolito (del "español" limpio al "inglés" viejo). El servicio nunca vio una palabra en el idioma viejo (cero claves legacy), y el monolito nunca vio una palabra en el idioma limpio (cero objetos Product). Si el servicio llegara a ver un prc_cents, o el monolito un Product, la membrana se rompió —y eso es justo lo que vamos a verificar que no pasa—.

Ejemplo trabajado: una request cruzando el borde dos veces

No vamos a describir la simetría: la vamos a ejecutar, trazando una request en su viaje completo. El monolito envía un dict legacy; el ACL lo traduce a Product (to_modern); el servicio trabaja sobre el Product y devuelve un Product (aplica una regla de negocio limpia: marca los agotados); el ACL lo traduce de vuelta a dict legacy (to_legacy); y el monolito lo muestra. En cada paso imprimimos qué modelo está en juego, y al final contamos las violaciones de la membrana.

from dataclasses import dataclass, asdict

@dataclass
class Product:
    id: int
    name: str
    price_cents: int
    active: bool

# --- ACL: dos traducciones, una por sentido. ---
def to_modern(row):
    return Product(row["prod_id"], row["desc"], int(row["prc_cents"]), row["act"] == "Y")

def to_legacy(product):
    return {"prod_id": product.id, "desc": product.name,
            "prc_cents": str(product.price_cents), "act": "Y" if product.active else "N"}

LEGACY_KEYS = {"prod_id", "desc", "prc_cents", "act"}

# --- El servicio nuevo: SOLO conoce Product. Aplica una regla de negocio limpia. ---
def modern_catalog_service(product):
    # Regla nueva del servicio: los inactivos se marcan agotados en el nombre.
    assert isinstance(product, Product), "el servicio solo acepta Product"
    if not product.active:
        return Product(product.id, product.name + " (agotado)", product.price_cents, False)
    return product

# --- El monolito: SOLO conoce dicts legacy. No sabe que Product existe. ---
def monolith_render(legacy):
    assert set(legacy) == LEGACY_KEYS, "el monolito solo acepta el dict legacy"
    return f"[{legacy['prod_id']}] {legacy['desc']} - ${int(legacy['prc_cents'])/100:.2f}"

# --- El flujo completo, con traza de que modelo se ve en cada borde. ---
requests = [
    {"prod_id": 2, "desc": "USB-C Hub", "prc_cents": "3200", "act": "Y"},
    {"prod_id": 3, "desc": "Webcam HD", "prc_cents": "0",    "act": "N"},
]

print("Una request cruza el borde dos veces: entra traducida, sale traducida\n")
saw_legacy_in_service = 0
saw_product_in_monolith = 0
for legacy_in in requests:
    inbound = to_modern(legacy_in)                 # borde de entrada: legacy -> Product
    served = modern_catalog_service(inbound)       # el servicio trabaja en Product
    legacy_out = to_legacy(served)                 # borde de salida: Product -> legacy
    rendered = monolith_render(legacy_out)         # el monolito trabaja en legacy

    saw_legacy_in_service += sum(1 for k in asdict(inbound) if k in LEGACY_KEYS)
    saw_product_in_monolith += 1 if isinstance(legacy_out, Product) else 0

    print(f"  monolito envia : {legacy_in}")
    print(f"  ACL->modern    : {inbound}")
    print(f"  servicio devuelve: {served}")
    print(f"  ACL->legacy    : {legacy_out}")
    print(f"  monolito muestra: {rendered}\n")

print("-" * 68)
print(f"Claves legacy que vio el servicio (debe ser 0): {saw_legacy_in_service}")
print(f"Objetos Product que vio el monolito (debe ser 0): {saw_product_in_monolith}")
print("\n  El ACL protege en los dos sentidos: el modelo limpio no se ensucia de")
print("  entrada, y el modelo viejo del monolito no se rompe a la salida.")

Qué esperar. Al correr el archivo, la salida es exactamente esta:

Una request cruza el borde dos veces: entra traducida, sale traducida

  monolito envia : {'prod_id': 2, 'desc': 'USB-C Hub', 'prc_cents': '3200', 'act': 'Y'}
  ACL->modern    : Product(id=2, name='USB-C Hub', price_cents=3200, active=True)
  servicio devuelve: Product(id=2, name='USB-C Hub', price_cents=3200, active=True)
  ACL->legacy    : {'prod_id': 2, 'desc': 'USB-C Hub', 'prc_cents': '3200', 'act': 'Y'}
  monolito muestra: [2] USB-C Hub - $32.00

  monolito envia : {'prod_id': 3, 'desc': 'Webcam HD', 'prc_cents': '0', 'act': 'N'}
  ACL->modern    : Product(id=3, name='Webcam HD', price_cents=0, active=False)
  servicio devuelve: Product(id=3, name='Webcam HD (agotado)', price_cents=0, active=False)
  ACL->legacy    : {'prod_id': 3, 'desc': 'Webcam HD (agotado)', 'prc_cents': '0', 'act': 'N'}
  monolito muestra: [3] Webcam HD (agotado) - $0.00

--------------------------------------------------------------------
Claves legacy que vio el servicio (debe ser 0): 0
Objetos Product que vio el monolito (debe ser 0): 0

  El ACL protege en los dos sentidos: el modelo limpio no se ensucia de
  entrada, y el modelo viejo del monolito no se rompe a la salida.

Sigue el viaje de la primera request, línea por línea, porque cada una es un cambio de idioma.

monolito envia : {'prod_id': 2, ...} — el monolito habla su modelo viejo, como siempre. No sabe que del otro lado hay un servicio con otro modelo; solo manda su dict legacy.

ACL->modern : Product(id=2, ...) — el borde de entrada. El ACL tradujo el dict viejo en un Product limpio. A partir de aquí, y hasta que la respuesta vuelva a cruzar el borde, todo es Product.

servicio devuelve: Product(id=2, ...) — el servicio trabajó puramente en Product. Recibió Product, devolvió Product. Su código nunca mencionó prc_cents ni desc; opera sobre name, price_cents, active. Vive en su idioma limpio.

ACL->legacy : {'prod_id': 2, ...} — el borde de salida. El ACL tradujo el Product de vuelta al dict legacy. Fíjate: price_cents=3200 (int) volvió a ser 'prc_cents': '3200' (string), active=True volvió a ser 'act': 'Y'. El ACL de vuelta des-normaliza a la forma exacta que el monolito espera.

monolito muestra: [2] USB-C Hub - $32.00 — el monolito trabajó puramente en su modelo viejo. Recibió un dict legacy, leyó legacy['prc_cents'] y legacy['desc'] como siempre. Nunca vio un Product.

Ahora mira la segunda request, la del producto agotado, porque muestra la simetría trabajando con una regla de negocio de por medio. El servicio recibe Product(id=3, ..., active=False), aplica su regla limpia (marcar los inactivos: name + " (agotado)") y devuelve Product(id=3, name='Webcam HD (agotado)', ...). El ACL de salida traduce ese resultado de vuelta al modelo viejo: {'desc': 'Webcam HD (agotado)', ...}. El monolito muestra [3] Webcam HD (agotado) - $0.00. La regla de negocio la aplicó el servicio en su modelo limpio, y el monolito recibió el resultado en su modelo viejo, sin enterarse de que hubo una traducción de por medio. El servicio evolucionó; el monolito no cambió.

Y el veredicto de abajo es lo que sella la lección: Claves legacy que vio el servicio (debe ser 0): 0 y Objetos Product que vio el monolito (debe ser 0): 0. Los dos contadores en cero. El servicio nunca tocó la jerga del viejo; el monolito nunca tocó el modelo limpio. La membrana aguantó en ambas direcciones. Eso es un ACL bidireccional bien puesto: cada mundo, encerrado en su idioma, conversando con el otro a través del traductor.

Profundización: por qué la dirección de vuelta es la que salva al monolito

Es fácil ver por qué hace falta la dirección de entrada (legacy → modern): sin ella, el servicio nuevo tendría que hablar el modelo sucio, que es lo que el módulo entero quiere evitar. Pero la dirección de vuelta (modern → legacy) es menos obvia y más crítica para la seguridad de la extracción, porque es la que mantiene la promesa al monolito: "vas a seguir recibiendo exactamente lo que recibías antes".

Piénsalo así. El monolito tiene, repartidos por todo su código, cientos de lugares que consumen el catálogo: el checkout, el carrito, los emails, el panel de admin. Todos esperan el modelo viejo —row["desc"], row["prc_cents"]—. Cuando extraes catalog a un servicio, no vas a reescribir esos cientos de consumidores para que hablen Product; eso sería un big-bang, justo lo que la guía combate. En vez de eso, el ACL de vuelta hace que el servicio, visto desde el monolito, se comporte idéntico a la función interna que reemplazó: recibe lo viejo, devuelve lo viejo. Los cientos de consumidores no se enteran de que detrás hay un servicio con otro modelo.

                   ACL bidireccional
                        │
  monolito ──[legacy]──>│──[modern]──> servicio
  (cientos de           │              (modelo limpio,
   consumidores,        │               logica nueva)
   sin cambiar)  <──────│<─────────────
                 [legacy]    [modern]
                        │
   la promesa: el monolito recibe lo viejo intacto, en ambos sentidos

Esto conecta con una propiedad que ya viste en la lección 1: el round-trip tiene que volver idéntico. El round-trip es el ACL bidireccional aplicado a un dato que no cambia: to_legacy(to_modern(row)) == row. Si el round-trip da True para todos los casos, sabes que la dirección de vuelta es fiel —que el monolito recibirá exactamente su modelo viejo—. Por eso la lección 1 insistía en verificarlo: el round-trip es la prueba de que la promesa al monolito se cumple.

Hay un matiz sobre las dos formas del ACL de vuelta. En este ejemplo, la respuesta del servicio cambió (marcó el producto agotado), y el ACL de vuelta tradujo el resultado nuevo al modelo viejo. Eso está bien: el servicio puede evolucionar su comportamiento (esa es media gracia de extraerlo), y el ACL de vuelta traduce lo que sea que el servicio produzca. Lo que el ACL de vuelta garantiza no es que la respuesta sea idéntica a la de antes —el servicio puede mejorar—, sino que la respuesta llegue en el formato que el monolito espera. Formato viejo, comportamiento que puede ser nuevo: esa es la separación que el ACL de vuelta mantiene.

Errores comunes

Construir solo la dirección de entrada y olvidar la de vuelta. Qué pasa: el equipo escribe to_modern (el servicio ya recibe Product) y da el ACL por hecho —hasta que el monolito recibe la respuesta del servicio y truena, porque le llegó un Product donde esperaba un dict—. Por qué pasa: la dirección de entrada es la visible (es la que hace que el servicio "funcione"); la de vuelta se descubre tarde, cuando el monolito consume la respuesta. Cómo detectarlo: el monolito falla al leer row["desc"] sobre algo que no es un dict, o hay que empezar a parchar consumidores del monolito para que entiendan Product. Cómo corregirlo: el ACL es bidireccional por definición. Escribe to_legacy junto con to_modern, y verifica el round-trip. La dirección de vuelta es la que cumple la promesa de no tocar el monolito; sin ella, extraer el servicio obliga a reescribir a todos sus consumidores —el big-bang que querías evitar—.

Filtrar la respuesta del servicio con lógica del monolito en el ACL de vuelta. Qué pasa: el ACL de vuelta, además de traducir el formato, empieza a ajustar la respuesta "para que el monolito esté contento" —redondear un precio, ocultar un campo nuevo—. Por qué pasa: el borde de salida parece el lugar para "acomodar" la respuesta al gusto del viejo. Cómo detectarlo: el ACL de vuelta tiene lógica que no es traducción de formato sino decisión sobre el contenido. Cómo corregirlo: el ACL de vuelta solo traduce el formato modern → legacy, fielmente. Si el servicio produce un comportamiento nuevo (marcar agotados, un precio distinto), el ACL lo traduce tal cual al formato viejo; no lo "corrige". Las decisiones sobre qué contenido devolver son del dominio del servicio. Meter lógica en el ACL de vuelta esconde comportamiento en el borde y rompe la separación formato/comportamiento.

Dejar que el Product se filtre al monolito "porque es más rico". Qué pasa: alguien nota que el Product limpio tiene mejores nombres y tipos, y decide pasar el Product directo a algunos consumidores del monolito "los que ya actualizamos", dejando a otros con el dict viejo. Por qué pasa: el modelo limpio es tentador; parece un upgrade gratis exponerlo. Cómo detectarlo: el contador "objetos Product que vio el monolito" deja de ser 0; hay consumidores del monolito que ahora dependen de Product y otros del dict —dos modelos conviviendo dentro del monolito—. Cómo corregirlo: mientras el monolito exista, el ACL le entrega solo el modelo viejo, a todos sus consumidores por igual. Si quieres que un consumidor hable el modelo limpio, ese consumidor tiene que salir del monolito también (extraerse), no recibir el Product por un agujero en la membrana. Mezclar los dos modelos dentro del monolito reintroduce justo la contaminación que el ACL previene, ahora en el sentido contrario.

Ejercicios

Ejercicio 1 — El intérprete de una sola dirección. En la analogía de la negociación, imagina un intérprete que traduce del inglés al español pero no de vuelta. (a) ¿Qué pasa con la conversación? (b) ¿A qué error concreto del ACL corresponde? (c) ¿Qué garantiza el buen intérprete sobre lo que cada socio "oye", y cómo se mide eso en el ejemplo?

Ver solución

(a) La conversación se rompe a la mitad. El socio inglés habla, el español entiende y responde en español, pero el inglés se queda sin entender la respuesta —porque nadie la tradujo de vuelta—. Una negociación necesita las dos direcciones para funcionar; con una sola, solo un lado se entera de lo que dice el otro.

(b) Corresponde a construir solo la dirección de entrada (to_modern) y olvidar la de vuelta (to_legacy). El servicio recibe y entiende la request (como el español que entiende al inglés), procesa y responde en su modelo limpio... pero el monolito no puede leer esa respuesta Product —se queda sin entender, y truena—. Falta traducir de vuelta.

(c) El buen intérprete garantiza que cada socio solo oye su propio idioma: el inglés nunca oye español, el español nunca oye inglés. En el ejemplo eso se mide con los dos contadores: "claves legacy que vio el servicio" (debe ser 0, el servicio nunca oyó el idioma viejo) y "objetos Product que vio el monolito" (debe ser 0, el monolito nunca oyó el idioma limpio). Los dos en cero significan que la membrana aguantó en ambas direcciones: cada mundo, encerrado en su idioma.

Ejercicio 2 — Sigue el producto agotado. En el ejemplo, la request del producto 3 entró como Product(..., active=False) y el servicio la devolvió como Product(name='Webcam HD (agotado)', ...). (a) ¿En qué modelo aplicó el servicio la regla de "marcar agotado"? (b) ¿Cómo llegó ese cambio al monolito? (c) ¿Por qué el monolito no tuvo que cambiar para mostrar "(agotado)"?

Ver solución

(a) El servicio aplicó la regla en su modelo limpio (Product): recibió Product y devolvió Product, operando sobre name y active. La lógica de negocio nueva ("los inactivos se marcan agotados") vive en el dominio del servicio, escrita sobre el modelo limpio —nunca tocó desc ni act—.

(b) Llegó al monolito a través del ACL de vuelta (to_legacy): el Product(name='Webcam HD (agotado)', ...) se tradujo al dict legacy {'desc': 'Webcam HD (agotado)', ...}. El comportamiento nuevo (el "(agotado)" en el nombre) viajó dentro del name, y el ACL lo puso de vuelta en el campo desc que el monolito lee. El cambio cruzó la membrana traducido al formato viejo.

(c) Porque el monolito sigue haciendo exactamente lo que hacía: leer legacy['desc'] y mostrarlo. No le importa qué dice el desc, solo lo muestra. El servicio cambió el contenido (agregó "(agotado)"), pero el formato que el monolito recibe es el de siempre (desc como string). Esa es la separación que el ACL de vuelta mantiene: el comportamiento puede evolucionar en el servicio, pero el formato que cruza al monolito no cambia, así que el monolito no necesita tocarse. El servicio mejora sin obligar al monolito a moverse.

Ejercicio 3 — La membrana rota. Un equipo, para "aprovechar el modelo limpio", hace que el panel de admin del monolito reciba el Product directo (sin traducir a legacy), mientras el resto del monolito sigue con el dict viejo. (a) ¿Qué contador del ejemplo dejaría de dar 0? (b) ¿Qué problema crea tener dos modelos dentro del monolito? (c) ¿Cuál es la forma correcta de que un consumidor hable el modelo limpio?

Ver solución

(a) Dejaría de dar 0 el contador "objetos Product que vio el monolito": ahora el panel de admin (que es parte del monolito) recibe y depende de Product. La membrana se rompió en el sentido modern → monolito: el idioma limpio se filtró al mundo viejo.

(b) Crea la contaminación en sentido contrario: dentro del monolito conviven ahora dos modelos —unos consumidores hablan el dict viejo, otros hablan Product—. Eso significa que un cambio en Product puede romper al panel de admin sin que el resto del monolito se entere, que hay que mantener dos formas del mismo dato, y que el "modelo viejo único" del monolito dejó de ser único. Es exactamente la clase de enredo que la extracción quería eliminar, reintroducido por un atajo. El ACL existe para que el monolito tenga un solo modelo (el viejo) y el servicio tenga un solo modelo (el limpio); mezclarlos rompe esa claridad.

(c) La forma correcta es que ese consumidor salga del monolito —se extraiga él también a un servicio, o se convierta en un cliente propio del servicio de catálogo que hable Product de forma legítima—. Un consumidor habla el modelo limpio cuando vive del lado limpio de la membrana, no cuando se le pasa un Product por un agujero mientras sigue dentro del monolito. Mientras un consumidor sea parte del monolito, recibe el modelo viejo a través del ACL, como todos los demás. Cruzar al modelo limpio es una decisión de extracción, no un atajo de conveniencia.

Resumen y siguiente paso

En esta lección completaste el ACL con su dirección de vuelta (modern → legacy) y viste las dos direcciones trabajando juntas. Con el intérprete que traduce en ambos lados de la mesa, entendiste que un traductor de una sola dirección rompe la conversación, y que el buen intérprete garantiza que cada lado solo oiga su propio idioma. Y lo ejecutaste: trazaste una request cruzando el borde dos veces —monolito → ACL → servicio → ACL → monolito—, viste al servicio trabajar puramente en Product y al monolito puramente en su dict viejo, incluso cuando el servicio aplicó una regla de negocio nueva (marcar agotados), y verificaste los dos contadores en cero: el servicio vio 0 claves legacy y el monolito vio 0 objetos Product. La membrana aguantó en ambos sentidos.

Antes de avanzar deberías poder: explicar por qué el ACL es bidireccional por definición; describir qué salva la dirección de vuelta (la promesa de no tocar a los cientos de consumidores del monolito); distinguir el formato (que el ACL mantiene viejo) del comportamiento (que el servicio puede evolucionar); y reconocer una membrana rota en cualquiera de los dos sentidos.

Con el ACL bidireccional listo, el modelo ya está separado: el monolito habla viejo, el servicio habla limpio, y el traductor los une. Pero falta el otro corte, el de los datos. La lección 5 ataca la propiedad de los datos: hasta ahora el servicio traduce el modelo, pero sus datos todavía pueden estar en las tablas del monolito. Vas a ver por qué un servicio extraído de verdad debe duenar sus datos —ser el único que lee y escribe su store— y vas a ejecutar un detector que atrapa al "servicio" falso que, a pesar del ACL, sigue leyendo las tablas del monolito. El modelo ya está limpio; ahora los datos tienen que ser propios.

Recursos

  • Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), capítulo 14, Anti-Corruption Layer — la descripción del ACL como una capa con "fachadas" y "adaptadores" en ambos sentidos de la integración, traduciendo en las dos direcciones entre los dos modelos. En inglés.
  • Sam Newman, Monolith to Microservices (O'Reilly, 2019), cap. 3 — la sección sobre mantener el contrato con los consumidores existentes mientras el servicio extraído usa un modelo interno distinto; el ACL de vuelta es lo que preserva ese contrato. En inglés.
  • Martin Fowler, "StranglerFigApplication" (2004) — martinfowler.com/bliki/StranglerFigApplication.html. El "event interception" y la idea de que el sistema nuevo debe presentarse al viejo con el contrato que el viejo espera, aunque por dentro use otro modelo. En inglés.
  • Microsoft, "Anti-corruption Layer pattern" — learn.microsoft.com/azure/architecture/patterns/anti-corruption-layer. La ficha del patrón, con el diagrama de la capa que traduce entre los dos subsistemas en ambas direcciones. En inglés.