wandres.dev
RENDIMIENTO Y ESCALA · máquinas grandes

Cuando la máquina crece demasiado

Una máquina grande no es necesariamente una máquina enferma, pero hay síntomas que se pueden contar y que anticipan el punto donde el modelo deja de ayudar a pensar. Esta lección propone métricas concretas de tamaño y acoplamiento, distingue los tres cortes posibles —región paralela, submáquina invocada y actor independiente— con su criterio de elección, muestra cómo ejecutar el corte preservando el comportamiento mediante input, output y mensajería explícita, y contabiliza con honestidad lo que la división cuesta en atomicidad, latencia y superficie de pruebas.

⏱ 20 min

El tamaño no es por sí mismo un defecto: hay dominios que tienen sesenta situaciones distintas y modelarlos con sesenta estados es exactitud, no exceso. El defecto aparece cuando el crecimiento deja de ser aditivo y empieza a ser multiplicativo, cuando añadir una funcionalidad obliga a tocar transiciones en cuatro sitios que no tienen nada que ver entre sí, cuando el contexto acumula campos que solo interesan a una tercera parte del grafo y cuando nadie del equipo es capaz de sostener el diagrama entero en la cabeza el tiempo suficiente para razonar sobre un cambio. Ese punto de inflexión no se detecta por corazonada, se detecta por síntomas que admiten cuenta y umbral. Y una vez detectado, la respuesta no es una sola: hay tres cortes posibles, cada uno con un criterio propio y un precio distinto, y elegir mal el corte produce una arquitectura peor que la máquina grande de la que se huía.

🎯 Al terminar esta lección sabrás
  • Medir el tamaño y el acoplamiento de una máquina con métricas contables en vez de impresiones.
  • Elegir entre región paralela, submáquina invocada y actor independiente según el criterio adecuado.
  • Ejecutar el corte preservando el comportamiento mediante input, output y mensajería explícita.
  • Contabilizar el precio de la división en atomicidad, latencia de coordinación y superficie de pruebas.

Los síntomas se cuentan, no se intuyen

La conversación sobre si una máquina es demasiado grande suele degenerar en estética. Se arregla sustituyendo adjetivos por cifras extraídas de la propia definición, que es un objeto plano y por tanto perfectamente analizable con un guion de veinte líneas.

Síntoma Cómo se mide Umbral orientativo
Volumen bruto número de nodos de estado en el grafo por encima de cuarenta, sospecha
Dispersión de un evento en cuántos nodos aparece el mismo tipo de evento por encima de seis, hay un aspecto suelto
Contexto irrelevante campos que solo lee una zona del grafo más de la mitad, hay dos máquinas dentro
Acoplamiento por guarda guardas que leen campos que otra región escribe cualquier caso merece revisión
Amplitud del cambio nodos tocados por una funcionalidad típica disperso por todo el archivo, mal corte
Concurrencia real regiones que avanzan de forma independiente dos o más, candidato claro a división

Las tres primeras filas miden tamaño y las tres últimas miden acoplamiento, y esa distinción es la que decide el diagnóstico. Una máquina de ochenta nodos con acoplamiento bajo es simplemente un dominio grande bien modelado, y trocearla solo repartirá el mismo tamaño en más archivos con juntas nuevas por mantener. Una máquina de veinticinco nodos donde cada cambio toca tres zonas y las guardas de una leen lo que escribe otra es una máquina enferma aunque sea pequeña, porque lo que se está midiendo ya no es longitud sino confusión de responsabilidades.

💡
La señal más fiable es la del evento repetido

Si el mismo tipo de evento aparece en muchos nodos con acciones parecidas, no estás ante un dominio complejo sino ante un aspecto transversal mal ubicado: autenticación, telemetría, conectividad o permisos, viviendo dentro de una máquina que trata de otra cosa. La cura no es refactorizar transiciones una por una, sino reconocer que ese aspecto tiene su propio ciclo de vida y merece su propio actor, con el que la máquina original se comunique por mensajes en lugar de hospedarlo.

Tres cortes y su criterio

Detectado el problema, la pregunta correcta es dónde pasa la frontera. Hay exactamente tres respuestas estructurales y confundirlas es el error más caro de este nivel.

🔀

Región paralela

Dos aspectos avanzan a la vez dentro de un mismo ciclo de vida y comparten contexto. No hay frontera de datos, solo de concurrencia. Se resuelve dentro de la misma máquina, sin mensajería ni actores nuevos.

📦

Submáquina invocada

Un subproceso con principio y fin claros, que recibe datos al empezar y devuelve un resultado al terminar. Vive mientras dure el estado que lo invoca y su final es un evento para el padre.

🛰️

Actor independiente

Un aspecto con ciclo de vida propio que no coincide con ningún estado del padre. Nace y muere por su cuenta, sobrevive a las transiciones ajenas y se comunica exclusivamente por mensajes.

El criterio para elegir se reduce a dos preguntas encadenadas. La primera es si los dos aspectos comparten ciclo de vida: si nacen y mueren juntos y necesitan leer el mismo contexto de forma síncrona, la respuesta es región paralela y cualquier corte en actores introducirá una coordinación que no hacía falta. La segunda, si no lo comparten, es si uno de ellos termina y devuelve algo: si tiene un final con resultado, es submáquina invocada; si sencillamente vive por su cuenta y emite señales cuando le parece, es actor independiente.

flowchart LR
subgraph antes
  A[maquina unica de sesenta nodos]
end
subgraph despues
  B[maquina coordinadora]
  B -->|invoke con input| C[submaquina de pago]
  B -->|spawn| D[actor de conectividad]
  C -->|output al terminar| B
  D -->|eventos de estado de red| B
end

Ejecutar el corte sin cambiar el comportamiento

Un corte bien hecho es una refactorización: el comportamiento observable antes y después debe ser idéntico. La técnica que lo garantiza es mecánica. Se extrae el subárbol tal cual, se identifican los campos de contexto que leía y se convierten en input, se identifican los datos que el resto necesitaba de él y se convierten en output, y se sustituyen las lecturas cruzadas por mensajes.

import { setup, assign, sendTo, fromPromise } from 'xstate'

const pago = setup({
  types: {
    input: {} as { importe: number; moneda: string },
    output: {} as { referencia: string },
  },
  actors: { cobrar: fromPromise(async () => ({ referencia: 'ref-1' })) },
}).createMachine({
  context: ({ input }) => ({ importe: input.importe, moneda: input.moneda, referencia: '' }),
  initial: 'cobrando',
  states: {
    cobrando: {
      invoke: {
        src: 'cobrar',
        onDone: { target: 'hecho', actions: assign({ referencia: ({ event }) => event.output.referencia }) },
        onError: 'fallido',
      },
    },
    hecho: { type: 'final' },
    fallido: { on: { REINTENTAR: 'cobrando' } },
  },
  output: ({ context }) => ({ referencia: context.referencia }),
})

const compra = setup({
  actors: { pago },
}).createMachine({
  initial: 'carrito',
  context: { total: 0, referencia: '' },
  states: {
    carrito: { on: { PAGAR: 'pagando' } },
    pagando: {
      invoke: {
        src: 'pago',
        input: ({ context }) => ({ importe: context.total, moneda: 'EUR' }),
        onDone: { target: 'confirmado', actions: assign({ referencia: ({ event }) => event.output.referencia }) },
      },
      on: { CANCELAR: { target: 'carrito', actions: sendTo('pago', { type: 'ABORTAR' }) } },
    },
    confirmado: { type: 'final' },
  },
})

Lo que hace correcto a ese corte no es la sintaxis sino la disciplina de frontera que impone. La submáquina de pago no puede leer el contexto del padre: recibe lo que necesita al nacer y devuelve lo que produjo al morir. Esa restricción, que al principio se vive como incomodidad, es exactamente el beneficio buscado, porque convierte una dependencia difusa —cualquier guarda podía leer cualquier campo— en un contrato de dos direcciones que cabe en cuatro líneas y se puede probar de forma aislada.

⚠️
No cortes por donde el archivo es largo

La tentación al abrir una definición de mil líneas es partirla por la mitad para que quepan dos archivos de quinientas. Un corte hecho por longitud y no por cohesión produce lo peor de ambos mundos: dos máquinas que se envían mensajes constantemente para reconstruir la información que antes tenían gratis, con la coordinación repartida entre ambas y sin que ninguna sea comprensible por separado. La medida correcta es el acoplamiento —cuántos mensajes hacen falta para que colaboren—, y si la respuesta es muchos y continuos, el corte estaba en el sitio equivocado.

Lo que la división cuesta

Dividir no es gratis, y presentar el corte como pura ganancia es la forma más rápida de acabar con una constelación de actores que nadie sabe depurar. Hay tres precios que conviene pagar con los ojos abiertos.

El primero es la pérdida de atomicidad. Dentro de una máquina, una transición es un paso indivisible: se sale, se asigna, se entra y solo entonces existe un snapshot nuevo, de modo que no hay estados intermedios observables. Entre dos actores no existe esa garantía; hay un intervalo en el que uno ya cambió y el otro todavía no ha recibido el mensaje, y toda invariante que dependa de que ambos coincidan debe ser reformulada como una convergencia eventual o protegida por un estado de espera explícito.

El segundo es la latencia de coordinación. Cada mensaje entre actores se procesa en un macrostep posterior, así que una secuencia que antes ocurría en una sola transición ahora ocupa varios turnos del buzón. Rara vez importa en términos de tiempo, pero sí importa en términos de razonamiento: el orden de llegada pasa a ser parte del modelo y hay que decidir explícitamente qué ocurre si un mensaje llega cuando el receptor ya no está en el estado que lo esperaba.

El tercero es la superficie de pruebas. Una máquina única se prueba enviando eventos y afirmando sobre estados; un sistema de actores exige además probar los contratos de entrada y salida, la mensajería y los casos de actor detenido antes de recibir la respuesta. Cada uno de esos puntos es una prueba nueva que antes no existía porque el problema no existía.

Dividir es elegir dónde poner las fronteras del razonamiento

La división de una máquina grande parece un problema de organización de código y es en realidad un problema epistemológico: se trata de decidir qué partes del sistema quieres poder entender por separado. Toda frontera que introduces es una promesa de que lo que hay dentro puede razonarse sin mirar afuera, y esa promesa se paga con la moneda de la coordinación explícita. Cuando el reparto acierta, el pago es ridículo comparado con lo que compra: cada pieza cabe en una cabeza, cada contrato se lee en cuatro líneas, cada prueba interroga una unidad con sentido y el cambio de una parte no obliga a releer las demás. Cuando el reparto falla, el mismo mecanismo se vuelve en contra con una violencia notable, porque las piezas siguen dependiendo unas de otras pero ahora lo hacen a través de mensajes asíncronos, es decir, con la dependencia intacta y las garantías perdidas. Por eso el criterio de corte nunca puede ser el tamaño del archivo, que es una propiedad del texto, sino la cohesión del ciclo de vida, que es una propiedad del dominio: dos cosas que empiezan juntas, terminan juntas y necesitan mirarse en el mismo instante pertenecen a la misma máquina aunque ocupen mil líneas, y dos cosas cuyos tiempos no coinciden pertenecen a actores distintos aunque quepan en veinte. Hay una consecuencia que conviene enunciar sin adornos: la arquitectura correcta de un sistema de actores no se deduce del código, se deduce del dominio, y por eso el trabajo más productivo cuando una máquina crece demasiado no es abrir el editor sino volver a preguntar qué procesos reales existen ahí dentro, cuáles nacen y mueren con cuáles, y qué necesita saber cada uno del otro. Las respuestas a esas tres preguntas dibujan las fronteras solas, y el refactor posterior se vuelve casi mecánico.

⚔️ Diagnostica y corta
  1. Escribe un guion que recorra la definición de tu máquina mayor y produzca las seis métricas de la tabla; anota los valores.
  2. Identifica el evento más disperso del grafo y decide si representa un aspecto transversal que merece actor propio.
  3. Aplica las dos preguntas del criterio a los dos subprocesos más independientes y justifica por escrito el corte elegido para cada uno.
  4. Extrae uno de ellos como submáquina invocada con input y output, sin cambiar una sola prueba existente de comportamiento observable.
  5. Enumera las invariantes que antes se cumplían por atomicidad y comprueba cuáles siguen garantizadas tras el corte; protege las que no.
  6. Mide de nuevo las seis métricas sobre la máquina coordinadora resultante y verifica que el acoplamiento bajó y no solo el número de líneas.