⚖️ Caso 07 — Comparativa multi-stack: Modernización incremental de monolito (PHP · Python · Node.js · Java · .NET · Go · Rust)#
TL;DR — el blast radius baja de
4modulos a1cuando el consumer ya migro, y a2cuando cae al monolito con ACL. Lo que cambia entre stacks es cuando falla un handler mal registrado: al escribir la migracion, o el martes que llega el primer request.
🐘 PHP · 🐍 Python · 🟢 Node.js · ☕ Java 21 · 🔵 .NET 8 · 🐹 Go 1.23 · 🦀 Rust 1.83
Estructura: 🎯 el problema → una seccion por stack → ⚖️ tabla de decision → 📊 primitiva por stack → 🏁 veredicto y ranking
🎯 El problema que ambos resuelven#
Un dominio de precios acoplado a un monolito. La variante legacy toca múltiples módulos en cada cambio, amplificando el blast radius. La variante strangler usa un ACL (Anti-Corruption Layer) que desacopla el dominio nuevo del legado y migra consumidores uno a uno.
🐘 PHP: stdClass como god class, unset para simular rotura, façade pattern#
Runtime: PHP-FPM. El estado del monolito se modela como un objeto stdClass con propiedades dinámicas. Las propiedades pueden ser eliminadas con unset(), simulando la rotura que ocurre cuando se elimina un módulo del que otros dependen.
El fallo legacy en PHP:
$monolithApp = new stdClass();
$monolithApp->billingModule = new BillingLegacy();
$monolithApp->inventoryModule = new InventoryLegacy();
$monolithApp->sharedSessionDb = new SharedDatabase();
// ...
// Alguien migra sharedSessionDb a un servicio externo y lo elimina
unset($monolithApp->sharedSessionDb);
// Cualquier otro módulo que lo use falla con Fatal Error
$monolithApp->billingModule->processPayment();
// PHP fatal: Attempt to read property "sharedSessionDb" on nullunset() sobre una propiedad de stdClass la elimina completamente. El acceso posterior lanza un Error fatal de PHP — no una Exception controlable, sino un Error del engine que detiene el proceso.
La corrección en PHP — Facade + ACL:
class BillingAdapter {
private BillingLegacy $legacy;
public function __construct(private PricingServiceNew $newService) {
$this->legacy = new BillingLegacy();
}
public function changePrice(string $productId, float $price): array {
// Traduce del modelo legacy al modelo nuevo
$legacyResult = $this->legacy->updatePrice($productId, $price);
// Propaga al nuevo servicio solo si el consumidor está migrado
if ($this->isMigrated($productId)) {
$this->newService->setPrice($productId, $price);
}
return $legacyResult;
}
}El BillingAdapter actúa como Facade que encapsula el acceso. Los consumidores que ya migraron acceden via el nuevo servicio; los que no, siguen por el path legacy. El ACL traduce entre los dos modelos.
Seguimiento de migración en PHP:
$state['migration']['consumers'][$consumer] = [
'migrated' => true,
'migrated_at' => gmdate('c'),
];🐍 Python: dict como god class, KeyError como rotura, dict como ACL#
Runtime: ThreadingHTTPServer. El estado del monolito se modela como un dict Python con acceso dinámico por clave. La eliminación de una clave con del simula la eliminación de un módulo.
El fallo legacy en Python:
monolith = {
"billing": BillingLegacy(),
"inventory": InventoryLegacy(),
"shared_session": SharedDatabase(),
}
# Alguien migra shared_session y lo elimina
del monolith["shared_session"]
# Cualquier acceso posterior lanza KeyError
monolith["billing"].process_payment()
# → accede internamente a monolith["shared_session"] → KeyErrordel monolith["shared_session"] elimina la clave. Cualquier acceso posterior lanza KeyError — la excepción Python para clave inexistente en dict. Equivalente al Fatal Error de PHP en stdClass.
La corrección en Python — ACL como dict mediador:
def build_billing_adapter(state: dict) -> dict:
return {
"translate": lambda product_id, price: {
"legacy_field": price,
"new_field": price,
"product_ref": product_id,
},
"route": lambda consumer: state["migration"]["consumers"].get(
consumer, {}
).get("migrated", False),
}
def run_strangler_change(product_id, new_price, consumer, state):
adapter = build_billing_adapter(state)
translation = adapter["translate"](product_id, new_price)
if adapter["route"](consumer):
# Consumidor migrado: usa el modelo nuevo
result = new_pricing_service(translation["new_field"])
else:
# Consumidor no migrado: usa el modelo legacy
result = legacy_billing(translation["legacy_field"])
# Registra la migración del consumidor
state["migration"]["consumers"][consumer] = {"migrated": True}
return resultEl ACL en Python es un dict de funciones (translate, route). Esto es más funcional y menos OOP que el PHP, pero logra el mismo aislamiento: los consumidores no acceden directamente al dominio — lo hacen a través del adaptador.
🟢 Node.js: Map<consumer, handler> mutable como tabla de routing strangler#
Runtime: Node.js 22. El monolito legacy se modela como un objeto plano con propiedades cuyo delete produce el equivalente al unset de PHP. La novedad Node es la tabla de routing del strangler como Map mutable en runtime.
El fallo legacy en Node:
const monolith = { billingEngine: {}, sharedSessionDb: { fetchData: () => 'session_blob' } };
if (scenario === 'shared_schema') {
delete monolith.sharedSessionDb;
const _ = monolith.sharedSessionDb.fetchData(); // TypeError nativo
}delete sobre una propiedad la quita del objeto. El acceso posterior lanza TypeError: Cannot read properties of undefined. Equivalente al Fatal Error de PHP y al KeyError de Python — pero como TypeError.
El strangler como Map registrable:
const newModuleHandlers = new Map();
const registerNewHandler = (consumer, handler) => newModuleHandlers.set(consumer, handler);
['web', 'mobile', 'backoffice'].forEach((c) =>
registerNewHandler(c, ({ scenario }) => ({
handled_by: 'extracted_module',
scenario_resolution: SCENARIO_CATALOG[scenario]?.hint,
}))
);
// En el flujo strangler:
const handler = newModuleHandlers.get(consumer);
handler({ scenario }); // Llama al modulo extraidoMover un consumidor al nuevo modulo es una linea: registerNewHandler('mobile', newHandler). No requiere instanciar clases, no requiere reload del proceso, no requiere DI container. La tabla de routing es la primitiva del lenguaje (Map), y los handlers son funciones de primera clase. La ACL es el closure que filtra el contrato: si el contrato cambia, el wrapping vive donde se registra.
Por que Map y no objeto literal: Map preserva orden de insercion, soporta cualquier tipo de clave, y Map.prototype.get/set son O(1) garantizados — una propiedad operacional importante cuando se registran muchos consumers.
☕ Java 21: ConcurrentHashMap<String, Function> + ACL como closure#
Runtime: JVM con thread pool. Lecturas de la routing table son frecuentes (cada request consulta); escrituras (registrar nuevo handler) son raras. ConcurrentHashMap ofrece reads sin lock y writes atomicos por bucket — exactamente el patron de lectura intensa, escritura ocasional.
El fallo legacy en Java:
// monolito: shared_schema acoplado, blast radius alto
int blastRadius = 4; // 4 modulos afectados al unisono
int risk = 8;La correccion en Java:
Map<String, Function<Request, Response>> routingTable = new ConcurrentHashMap<>();
routingTable.put("billing:change", req -> new Response("ok-new-module", "new-billing-svc", 1, 1));
Function<Request, Response> handler = routingTable.get(consumer + ":" + op);
if (handler != null) return handler.apply(req); // nuevo modulo
return legacyMonolith(req); // fallback acotado con ACLPor que Function y no interfaz nominal: Function<T,R> es una interfaz funcional del JDK — lambdas y method references la implementan directo. La firma del handler es el contrato; agregar un consumer es registrar una lambda. Sin reload del proceso, sin restart.
🔵 .NET 8: ConcurrentDictionary con Func<Request,Response> como tabla de routing mutable#
Runtime: .NET 8 sobre HttpListener. CLR ThreadPool. La tabla de routing vive en memoria del proceso unico, compartida entre handlers.
El fallo legacy en C#:
// todos los consumers pegan al mismo monolito
int blastRadius = 4; // 4 modulos afectados al unisono
int risk = 8;Cualquier cambio en shared_schema toca billing, partners, checkout, backoffice — sin contencion en la tabla porque no hay tabla, todo pasa por el monolito.
La correccion en C#:
private static readonly ConcurrentDictionary<string, Func<Request, Response>> routingTable = new();
routingTable["billing:change"] = req => new Response("ok-new-module", "new-billing-svc", 1, 1);
if (routingTable.TryGetValue($"{consumer}:{op}", out var handler))
return handler(req); // nuevo modulo
return LegacyMonolith(req); // fallback acotado con ACLFunc<Request, Response> es delegate generico — cualquier lambda lo implementa. La firma del handler es el contrato. Migrar un consumer = routingTable[$"{consumer}:{op}"] = lambda.
Notas idiomaticas vs los otros stacks:
Func<T,R>es 1:1 conFunction<T,R>Java o las arrow functions Node.ConcurrentDictionary<K,V>.TryGetValuereemplazaConcurrentHashMap.get()Java + null check.record Request/Response(C# 9+) son inmutables conwith-expressions — equivalente directo de losrecordJava.- A diferencia de PHP/Python, agregar un handler no necesita reload del proceso ni archivo de configuracion — vive en la memoria del proceso unico mientras este corre.
🐹 Go 1.23: la firma es el tipo del handler#
type handlerFunc func(request) responseJava necesita Function<Request,Response> y .NET Func<Request,Response> — tipos genericos de biblioteca envolviendo el concepto. En Go el tipo se declara con la firma.
Donde falla el error: registrar un handler con la firma equivocada es un error de compilacion en el punto de registro — cuando escribis la migracion, no cuando el primer request del consumer migrado llega a produccion.
RWMutex y no sync.Map: el patron de acceso es asimetrico. Se lee en cada request y se escribe una vez por migracion; RWMutex deja entrar a todos los lectores en paralelo, mientras sync.Map esta optimizado para el caso contrario.
🦀 Rust 1.83: Send + Sync verificados en el punto de registro#
type Handler = Box<dyn Fn(&Request) -> Response + Send + Sync>;Los dos marcadores del final no son decoracion:
Send→ el valor puede moverse entre threads.Sync→ puede compartirse por referencia entre threads.
El compilador los verifica al registrar. Si alguien intenta registrar un closure que captura algo no thread-safe —un Rc, un RefCell— el codigo no compila.
En Java, un Function<Request,Response> guardado en un ConcurrentHashMap puede capturar estado mutable no sincronizado sin que nadie avise: el mapa es concurrente, el closure no. En un strangler eso importa mas que en otros contextos, porque los handlers nuevos se registran mientras hay trafico y son justo el codigo menos probado del sistema.
⚖️ Diferencias de decision, no de correccion#
Los siete stacks implementan el mismo algoritmo. Esta tabla contrasta como lo expresa cada uno.
| Aspecto | PHP | Python | Node.js | Java | .NET | Go | Rust | Razon |
|---|---|---|---|---|---|---|---|---|
| Tipo del handler | callable | Callable | funcion | Function<Req,Res> | Func<Req,Res> | type handlerFunc func(request) response | Box<dyn Fn(..) + Send + Sync> | En Go la firma es el tipo; en Java/.NET es un generico de biblioteca envolviendo el concepto. |
| Cuando falla un registro erroneo | en runtime | en runtime | en runtime | compilacion | compilacion | compilacion | compilacion | Los cuatro tipados avisan al escribir la migracion, no en produccion. |
| ¿Verifica thread-safety del closure? | no | no | no aplica (single-thread) | no — el mapa es concurrente, el closure no | no | no | si — Send + Sync | Un handler nuevo que capture estado no sincronizado es el codigo menos probado del sistema. |
| Estructura de la tabla | array | dict | Map | ConcurrentHashMap | ConcurrentDictionary | map + RWMutex | HashMap + RwLock | Go y Rust eligen RW porque se lee en cada request y se escribe una vez por migracion. |
El patron Strangler Fig es idéntico en los tres: un mediador intercepta el acceso al dominio, traduce entre modelos, y enruta al servicio nuevo solo para los consumidores que ya migraron. Lo distintivo de Node: la tabla de routing es el Map y los handlers son funciones de primera clase — el patron desaparece casi por completo, queda solo la primitiva del lenguaje.
📊 Primitiva central por stack#
Los siete stacks resuelven el mismo problema. Lo que cambia es la primitiva y donde duele.
| Stack | Primitiva central en este caso |
|---|---|
| PHP | routing por config |
| Python | dict[str, Callable] |
| Node.js | Map<consumer, handler> |
| Java 21 | ConcurrentHashMap<String,Function> |
| .NET 8 | Func<Request,Response> delegate |
| Go 1.23 | map[string]handlerFunc — la firma es el tipo, sin generico envolvente |
| Rust 1.83 | Box<dyn Fn(..) + Send + Sync> — thread-safety verificada al registrar |
🏁 Veredicto: que stack resuelve mejor este problema#
⚠️ Ranking de fit, no de calidad de lenguaje. Mide que tan directamente las primitivas nativas del runtime expresan la solucion de este caso concreto. El orden cambia — a veces se invierte — de un caso a otro.
| Stack | Por que | |
|---|---|---|
| 🥇 | Rust 1.83 | Box<dyn Fn(..) + Send + Sync>: el compilador verifica en el punto de registro que el handler es seguro de compartir. En un strangler eso importa porque los handlers nuevos se registran con trafico encima. |
| 🥈 | Go 1.23 | La firma es el tipo, sin generico envolvente, y RWMutex respeta el patron de acceso real (muchas lecturas, una escritura por migracion). |
| 🥉 | Java 21 / .NET 8 | Function/Func en un mapa concurrente. Correcto, pero el mapa es thread-safe y el closure no — y nadie avisa. |
| 4º | Node.js 22 | Map<consumer, handler> mutable en runtime, legible y directo; sin red de tipos. |
| 6º | Python 3.12 / PHP 8.3 | dict de callables. Funciona; el error de firma aparece cuando llega el request. |
Lectura honesta: Los cuatro tipados avisan al escribir la migracion en vez de en produccion. Rust ademas cubre el caso que los otros tres no ven: el closure que captura estado no sincronizado.