🧪 Problem-Driven Systems Lab

⚖️ Caso 12 — Comparativa multi-stack: Punto único de conocimiento y riesgo operacional (PHP · Python · Node.js · Java · .NET · Go · Rust)#

TL;DR — legacy revienta con owner ausente (mttr 120); distributed degrada al runbook del equipo (mttr ~44) y /share-knowledge sube el bus factor. Siete formas de preguntar "¿y si no hay nadie?", y solo una donde el compilador exige la respuesta.

🐘 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 sistema de respuesta a incidentes donde el conocimiento crítico está concentrado en una sola persona (héroe). La variante legacy depende de que esa persona esté disponible. La variante distributed combina runbooks documentados, personas de backup y simulacros de incidentes para resolver sin el héroe.


🐘 PHP: declare(strict_types), ErrorException, Null Coalescing, tipado defensivo#

Runtime: PHP-FPM. El conocimiento tribal en PHP se modela como accesos a estructuras de datos implícitas sin validación. El tipado estricto de PHP 8 hace los errores más visibles y los contratos más explícitos.

El fallo legacy en PHP — conocimiento implícito:

php
declare(strict_types=1);   // El tipado estricto activa TypeError en mismatches

function resolveIncidentLegacy(array $opaqueData, string $domain): array {
    // Acceso sin validación a estructura implícita — falla si no existe
    $active = $opaqueData['config']['system'][2]['is_active'];
    // Si la estructura no tiene ese path: PHP 8 lanza TypeError o Warning
    // Código que solo entiende quien lo escribió originalmente

    if (!isset($heroMap[$domain])) {
        throw new \RuntimeException("No hay experto para el dominio: $domain");
    }
    $hero = $heroMap[$domain];
    if (!$hero['available']) {
        return ['escalated' => true, 'mttr_min' => 180, 'reason' => 'hero_absent'];
    }
}

$opaqueData['config']['system'][2]['is_active'] es un acceso con conocimiento tribal: solo el autor sabe qué estructura tiene. declare(strict_types=1) hace que un tipo incorrecto lance TypeError en lugar de hacer coerción silenciosa. El resultado es un sistema frágil que falla ruidosamente cuando el conocimiento no está documentado.

La corrección en PHP — defensive typing + Null Coalescing:

php
function resolveIncidentDistributed(array $data, string $domain, array $knowledge): array {
    $readinessScore = calculateReadinessScore($knowledge);

    // Null Coalescing para acceso defensivo — nunca lanza TypeError
    $active = $data['system'][2]['is_active'] ?? false;
    $runbook = $knowledge['runbooks'][$domain] ?? null;
    $backup  = $knowledge['backup_people'][$domain] ?? [];

    if ($readinessScore >= 60 && $runbook !== null) {
        return [
            'resolved_without_hero' => true,
            'mttr_min' => (int)(45 * (1 - $readinessScore / 100)),
            'path' => 'runbook + backup',
        ];
    }
    return ['escalated' => true, 'mttr_min' => 180];
}

function calculateReadinessScore(array $knowledge): float {
    $runbookScore = $knowledge['runbook_score'] ?? 0;
    $backupPeople = count($knowledge['backup_people'] ?? []);
    $drillScore   = $knowledge['drill_score'] ?? 0;
    return $runbookScore * 0.45 + ($backupPeople + 1) * 18 + $drillScore * 0.25;
}

?? en cada acceso garantiza que nunca se lanza TypeError o Warning por clave ausente. La fórmula de readinessScore convierte el conocimiento distribuido en un score numérico que determina si se puede resolver sin el héroe.


🐍 Python: acceso con .get(), operador or, score como función pura#

Runtime: ThreadingHTTPServer. El estado de conocimiento vive en un dict de módulo compartido entre hilos. threading.Lock protege las escrituras concurrentes.

El fallo legacy en Python — conocimiento tribal:

python
def resolve_incident_legacy(opaque_data: dict, domain: str) -> dict:
    # Acceso con conocimiento implícito — falla si la estructura no existe
    active = opaque_data["config"]["system"][2]["is_active"]
    # → KeyError si cualquier nivel del path no existe
    # Solo el autor original sabe cómo está estructurado esto

    hero = HERO_MAP.get(domain)
    if not hero or not hero.get("available", False):
        return {"escalated": True, "mttr_min": 180, "reason": "hero_absent"}

opaque_data["config"]["system"][2]["is_active"] lanza KeyError en cualquier nivel si la clave no existe. En PHP el error es TypeError o Warning; en Python es KeyError — ambos igual de opacos para quien no conoce la estructura.

La corrección en Python — .get() + función pura:

python
def readiness_score(knowledge: dict) -> float:
    """Convierte conocimiento distribuido en score numérico. Función pura."""
    runbook  = knowledge.get("runbook_score", 0)
    backups  = len(knowledge.get("backup_people", []))
    drills   = knowledge.get("drill_score", 0)
    return runbook * 0.45 + (backups + 1) * 18 + drills * 0.25

def resolve_incident_distributed(data: dict, domain: str, knowledge: dict) -> dict:
    score = readiness_score(knowledge)

    # .get() con defaults en cada nivel — nunca lanza KeyError
    active  = data.get("system", [{}]*3)[2].get("is_active", False)
    runbook = knowledge.get("runbooks", {}).get(domain)
    backup  = knowledge.get("backup_people", [])

    if score >= 60 and runbook is not None:
        return {
            "resolved_without_hero": True,
            "mttr_min": int(45 * (1 - score / 100)),
            "path": "runbook + backup",
        }
    return {"escalated": True, "mttr_min": 180}

readiness_score() es una función pura (sin efectos secundarios) que convierte el estado de conocimiento en un número. .get() con defaults en cada nivel garantiza que nunca se lanza KeyError. La fórmula es idéntica a PHP (runbook * 0.45 + (backups + 1) * 18 + drills * 0.25).

share-knowledge en Python — sin telemetría de request:

python
elif uri == "/share-knowledge":
    # Operación administrativa: no registra latencia en telemetría
    # El MTTR y readiness_score cambian, no el p95 de la ruta
    knowledge = update_knowledge(query)
    payload = {"readiness_score": readiness_score(knowledge), ...}
    skip_store_metrics = True

Mismo criterio que PHP: /share-knowledge no contamina los percentiles de latencia de las rutas de incidente. Es una operación administrativa, no un flujo de negocio.


🟢 Node.js: optional chaining ?. como runbook codificado#

Runtime: Node.js 22. La gracia del caso en Node es que el lenguaje tiene un operador (optional chaining, ECMAScript 2020) que codifica el runbook directamente — la diferencia entre legacy y distributed se reduce a tres caracteres.

El fallo legacy en Node:

javascript
// Acceso ciego — equivalente a memoria tribal sin validacion
const opaque = {};
const _ = opaque.config.system[2].is_active;
// → TypeError: Cannot read properties of undefined (reading 'config')

Idéntico al PHP Warning/TypeError y al Python KeyError: el sistema rompe ruidosamente.

La correccion distribuida en Node — el runbook **es el operador:**

javascript
// El runbook codificado en el lenguaje
const _ = opaque?.config?.system?.[2]?.is_active ?? false;
//             ^^^         ^^^         ^^^^^         ^^
//      "si no existe, default seguro y sigue"

?. evalua de izquierda a derecha y, si cualquier eslabon es null/undefined, retorna undefined sin lanzar. ?? provee el default. Es la encarnacion en el lenguaje de la regla "si no esta documentado, asume el default seguro y reporta" — tres caracteres que codifican una decision operacional.

El score de readiness:

javascript
const readinessScore = (d) =>
  Math.round(((d.runbook_score || 0) * 0.45) + (((d.backup_people || 0) + 1) * 18) + ((d.drill_score || 0) * 0.25));

const shareKnowledge = (domain, activity) => {
  const state = readState();
  const d = state.knowledge.domains[domain];
  if (activity === 'runbook') d.runbook_score = Math.min(100, (d.runbook_score || 0) + 20);
  else if (activity === 'pairing') d.backup_people = Math.min(4, (d.backup_people || 0) + 1);
  else d.drill_score = Math.min(100, (d.drill_score || 0) + 18);
  writeState(state);
};

Misma formula que PHP/Python. La diferencia esta en como Node maneja el "si no existe": || (similar al or de Python — ojo con 0).


☕ Java 21: Optional<T> + map/flatMap/orElse como runbook codificado#

Runtime: El sistema de tipos obliga a tomar postura ante "owner ausente" cuando devolves Optional<Owner> en lugar de Owner (que puede ser null). El crash legacy no es falla de Java — es falla de no usar las herramientas que Java ya ofrece.

El fallo legacy en Java:

java
Owner owner = pickOwnerLegacy(scenario);     // null si owner_absent
String script = owner.runbook().get(...);     // NPE
String executed = script.toUpperCase();        // NPE en cadena
// → catch: mttr 120 min, crashed

La correccion en Java:

java
Optional<Owner> ownerOpt = pickOwnerDistributed(scenario);   // empty si ausente
Optional<String> scriptOpt = ownerOpt.map(o -> o.runbook().get(runbookKey));
String script = scriptOpt.orElse(null);
// degradacion controlada: usa team runbook → mttr 35-50 min

Por que Optional y no null checks manuales: misma decision que ?. en Node, ? en Kotlin, ?? en C#: codificar la posibilidad de ausencia en el sistema de tipos, no en disciplina del developer. Optional<Owner> obliga a manejar el caso vacio; Owner owner no.


🔵 .NET 8: ?. (null-conditional) + ?? (null-coalescing) + Nullable Reference Types#

Runtime: .NET 8 sobre HttpListener. CLR ThreadPool. Registry de owners thread-safe + runbooks compartidos como fallback.

El fallo legacy en C#:

csharp
Owner owner = PickOwnerLegacy(scenario);       // null si owner_absent
string script = owner.Runbook[runbookKey];      // NullReferenceException
string executed = script.ToUpperInvariant();    // NRE en cadena
// → catch: mttr 120 min, crashed

La correccion en C#:

csharp
Owner? owner = PickOwnerDistributed(scenario);                     // null permitido
string? script = owner?.Runbook?.GetValueOrDefault(runbookKey);    // null si falta cualquier eslabon
string fallback = script ?? teamRunbooks[runbookKey];              // degrada al runbook compartido
// degradacion controlada → mttr 35-50 min

?. corta la cadena ante el primer null; ?? provee el fallback. Con <Nullable>enable</Nullable> en el csproj, el compilador emite warning si se desreferencia Owner? sin chequear — el problema se ve en build, no en runtime.

Notas idiomaticas vs los otros stacks:

  • ?. C# es 1:1 con ?. Node y Kotlin/Swift. Resuelve lo mismo que Optional.map().orElse() Java pero con sintaxis ligera.
  • ?? C# es 1:1 con ?? Node (ECMAScript 2020) — fusion estricta solo sobre null (a diferencia de Python or que es falsy-loose).
  • Interlocked.Add(ref coverage, 15) reemplaza AtomicInteger.addAndGet(15) Java.
  • ConcurrentDictionary<string, Owner> reemplaza el ConcurrentHashMap<String, Owner> Java.
  • record Owner(string Name, Dictionary<string, string> Runbook) con with-expressions es 1:1 con el record Java.
  • La diferencia profunda con Java: el modelo de tipos nulables en C# moderno es del compilador, no de runtime — Owner? y Owner son el mismo tipo en IL, pero el compilador rastrea el flow analysis y advierte sobre desreferencias inseguras.


🐹 Go 1.23: comma-ok, y el panic que igual hay que contener#

go
o := pickOwnerLegacy(scenario)   // *owner, puede ser nil
script := o.Runbook[runbookKey]  // panic: nil pointer dereference

La firma devuelve *owner y nada obliga a comprobarlo — ese es el bug que la variante legacy demuestra. recover() en un defer es lo unico que impide que el incidente tumbe el proceso.

La variante distributed usa comma-ok, que pone la ausencia en la firma:

go
func pickOwnerDistributed(scenario string) (*owner, bool)

El llamador no puede usar el valor sin recibir tambien el booleano. Pero v, _ := m[k] sigue siendo legal: Go permite descartar el ok.


🦀 Rust 1.83: Option<T> + ?, el unico donde el compilador exige el chequeo#

Los siete lenguajes resuelven la misma pregunta —"¿y si no hay owner?"— con herramientas distintas:

StackHerramienta¿Se puede ignorar el chequeo?
PHP / Pythonisset() / if x is NoneSi — olvidarlo es un error en runtime
Nodeoptional chaining ?.Si — undefined se propaga en silencio
JavaOptional<T>Si — .get() sin isPresent() compila
.NETnullable reference typesSi — el chequeo es un warning, no un error
Gocomma-ok v, ok := m[k]Si — v, _ := m[k] es legal
RustOption<T> + match exhaustivoNo — omitir el brazo None no compila

Y el operador ? hace que el camino correcto sea tambien el mas corto de escribir, que es la unica forma de que una convencion sobreviva a un equipo real:

rust
let owner  = pick_owner_distributed(scenario)?;
let script = owner.runbook.get(runbook_key)?;

Lo que Rust NO hace: impedir el atajo. .unwrap() existe, es una palabra, y convierte cualquier ausencia en un panic. Este caso lo usa a proposito en la variante legacy para demostrarlo. Un .unwrap() en produccion es exactamente el mismo olor que un Optional.get() sin isPresent() — la diferencia es que se puede grepear en una sola pasada.

⚖️ Diferencias de decision, no de correccion#

Los siete stacks implementan el mismo algoritmo. Esta tabla contrasta como lo expresa cada uno.

AspectoPHPPythonNode.jsJava.NETGoRustRazon
Expresion de la ausenciaisset()is None?.Optional<T>nullable ref typescomma-ok v, okOption<T>Siete formas de decir "puede no haber".
¿Se puede ignorar el chequeo?sisisi — se propaga undefinedsi — .get() compilasi — es solo un warningsi — v, _ := m[k]no — omitir None no compilaUnica fila que importa de verdad en este caso.
Propagacion sin ifnono?. encadenadomap/flatMap?.nooperador ?En Rust el camino correcto es tambien el mas corto de escribir.
Atajo peligroso disponiblesisisiOptional.get()! null-forgivingdescartar ok.unwrap()Rust no impide el atajo; lo concentra en una palabra grepeable.
Contencion del fallotry/catchtry/excepttry/catchtry/catchtry/catchrecover() en deferpanic::catch_unwindTodos evitan que un incidente tumbe el proceso.

Lo distintivo de Node: optional chaining (?.) hace el contraste legacy/distributed casi tipografico — la "ausencia de runbook" es visible en el codigo como ausencia del operador. PHP y Python lo aproximan con ?? y .get() pero con menos elegancia en estructuras anidadas profundas. La leccion operativa es la misma: el conocimiento implicito siempre se cobra; el conocimiento codificado en defaults seguros sobrevive a la rotacion.


📊 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
PHPisset() / ??
Pythonif x is None / dict.get()
Node.jsoptional chaining ?.
Java 21Optional<T> + map/orElse.get() sin isPresent() compila
.NET 8nullable reference types — el chequeo es warning, no error
Go 1.23comma-ok v, ok := m[k]v, _ := sigue siendo legal; recover()
Rust 1.83Option<T> + match exhaustivo: omitir None no compila; ? propaga

🏁 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.83Option<T> con match exhaustivo: omitir el brazo None no compila. Y el operador ? hace que el camino correcto sea el mas corto de escribir, que es la unica forma de que una convencion sobreviva a un equipo real.
🥈Go 1.23comma-ok pone la ausencia en la firma; el llamador recibe el booleano aunque pueda descartarlo con _.
🥉Java 21Optional<T> con map/orElse es expresivo, pero .get() sin isPresent() compila igual.
.NET 8Nullable reference types avisan, pero el aviso es un warning y el operador ! lo silencia.
Node.js 22?. es comodo y propaga undefined en silencio hasta que explota tres capas mas arriba.
Python 3.12 / PHP 8.3is None e isset(): disciplina pura, cero respaldo del lenguaje.

Lectura honesta: El caso trata de que el conocimiento no viva en una sola persona. La metafora tecnica es exacta: el valor que no esta y nadie comprobo. Rust es el unico donde el compilador hace de segundo owner.

Ver esta carpeta en GitHub ↗