🧪 Problem-Driven Systems Lab

⚖️ 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 4 modulos a 1 cuando el consumer ya migro, y a 2 cuando 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:

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 null

unset() 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:

php
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:

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:

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"] → KeyError

del 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:

python
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 result

El 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:

javascript
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:

javascript
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 extraido

Mover 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:

java
// monolito: shared_schema acoplado, blast radius alto
int blastRadius = 4;   // 4 modulos afectados al unisono
int risk = 8;

La correccion en Java:

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 ACL

Por 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#:

csharp
// 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#:

csharp
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 ACL

Func<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 con Function<T,R> Java o las arrow functions Node.
  • ConcurrentDictionary<K,V>.TryGetValue reemplaza ConcurrentHashMap.get() Java + null check.
  • record Request/Response (C# 9+) son inmutables con with-expressions — equivalente directo de los record Java.
  • 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#

go
type handlerFunc func(request) response

Java 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#

rust
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.

AspectoPHPPythonNode.jsJava.NETGoRustRazon
Tipo del handlercallableCallablefuncionFunction<Req,Res>Func<Req,Res>type handlerFunc func(request) responseBox<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 erroneoen runtimeen runtimeen runtimecompilacioncompilacioncompilacioncompilacionLos cuatro tipados avisan al escribir la migracion, no en produccion.
¿Verifica thread-safety del closure?nonono aplica (single-thread)no — el mapa es concurrente, el closure nononosi — Send + SyncUn handler nuevo que capture estado no sincronizado es el codigo menos probado del sistema.
Estructura de la tablaarraydictMapConcurrentHashMapConcurrentDictionarymap + RWMutexHashMap + RwLockGo 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.

StackPrimitiva central en este caso
PHProuting por config
Pythondict[str, Callable]
Node.jsMap<consumer, handler>
Java 21ConcurrentHashMap<String,Function>
.NET 8Func<Request,Response> delegate
Go 1.23map[string]handlerFuncla firma es el tipo, sin generico envolvente
Rust 1.83Box<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.

StackPor que
🥇Rust 1.83Box<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.23La firma es el tipo, sin generico envolvente, y RWMutex respeta el patron de acceso real (muchas lecturas, una escritura por migracion).
🥉Java 21 / .NET 8Function/Func en un mapa concurrente. Correcto, pero el mapa es thread-safe y el closure no — y nadie avisa.
Node.js 22Map<consumer, handler> mutable en runtime, legible y directo; sin red de tipos.
Python 3.12 / PHP 8.3dict 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.

Ver esta carpeta en GitHub ↗