wandres.dev
MÁQUINAS Y DATOS · combinar con el servidor

El anti-patrón: la máquina que reimplementa una cache

Hay un momento en la vida de casi toda máquina de estado en el que alguien guarda el resultado de una lectura en el contexto para no pedirlo dos veces. De ahí a un mapa por clave, una marca de tiempo, una guarda de frescura y una acción de invalidación hay tres semanas de trabajo honesto que producen una cache peor que la que ya tenías gratis. Esta lección diagnostica el anti-patrón por sus señales, explica por qué la cache casera pierde siempre por razones estructurales y no de esfuerzo, y da el refactor que devuelve cada dato a su dueño legítimo.

⏱ 18 min

El anti-patrón nunca se decide: se acumula. Nadie escribe un lunes por la mañana la resolución de implementar una cache dentro de un statechart. Lo que ocurre es que un assign guarda un resultado para no volver a pedirlo, y suena razonable; que una guarda comprueba si ya lo tenemos, y suena eficiente; que aparece un mapa por identificador cuando el flujo pasa a manejar varios recursos; que alguien añade una marca de tiempo porque el dato se quedaba viejo; y que al final del trimestre el contexto de la máquina contiene una tabla con política de caducidad, invalidación manual y un comentario pidiendo perdón. Cada paso fue local y defendible; el resultado es una cache sin deduplicación, sin compartición, sin recolección y sin herramientas, incrustada en el único sitio del sistema que no debía saber nada de frescura.

🎯 Al terminar esta lección sabrás
  • Diagnosticar el anti-patrón por sus señales antes de que sea caro deshacerlo.
  • Entender por qué la cache casera pierde por razones estructurales y no por falta de esfuerzo.
  • Distinguir una cache ilegítima de una instantánea con significado de dominio, que sí pertenece al contexto.
  • Aplicar el refactor que devuelve cada dato a su dueño y adelgaza el contexto a identidad y decisiones.

El síntoma: un mapa en el contexto

El código del anti-patrón es reconocible de un vistazo porque tiene un vocabulario propio que no pertenece al dominio de ninguna aplicación: claves, frescura, caducidad, invalidación.

type Contexto = {
  cache: Record<string, { dato: Ficha; en: number }>
  claveActual: string
}

const maquina = setup({
  guards: {
    yaLoTengoFresco: ({ context }) => {
      const entrada = context.cache[context.claveActual]
      return entrada !== undefined && Date.now() - entrada.en < 60_000
    },
  },
  actions: {
    guardar: assign({
      cache: ({ context, event }) => ({
        ...context.cache,
        [context.claveActual]: { dato: event.output, en: Date.now() },
      }),
    }),
    invalidarTodo: assign({ cache: {} }),
  },
}).createMachine({
  states: {
    decidiendo: {
      always: [{ target: 'listo', guard: 'yaLoTengoFresco' }, { target: 'leyendo' }],
    },
    leyendo: { invoke: { src: 'leerFicha', onDone: { target: 'listo', actions: 'guardar' } } },
    listo: { on: { OTRA_FICHA: { target: 'decidiendo', actions: 'elegirClave' } } },
  },
})

Nada de esto está mal escrito. La guarda es correcta, la acción es inmutable, el estado transitorio con always es idiomático. El problema es de ubicación: acabas de construir, dentro de un modelo cuyo propósito era describir el orden legal de un procedimiento, un almacén de datos con política temporal. El grafo ya no responde solo a dónde estoy y qué puedo hacer; ahora también responde a qué sé y desde cuándo, y esas dos preguntas envejecen a ritmos distintos y las tocan personas distintas.

⚠️
La cache que muere con el flujo es peor que no tener cache

Hay un detalle que suele descubrirse tarde: el contexto pertenece al actor, así que esa cache se destruye cuando la máquina se detiene. Si la máquina vive en una pantalla, al navegar fuera y volver el mapa está vacío y todo se vuelve a pedir. Has pagado la complejidad de una cache y obtienes el comportamiento de no tener ninguna, con el agravante de que mientras la pantalla estuvo abierta sirvió datos viejos que ninguna revalidación tocaba. Es el peor cuadrante posible: coste de cache, garantías de variable local.

Las señales de que te estás pasando de listo

El diagnóstico no requiere leer la máquina entera. Basta con buscar cuatro rasgos en el contexto y en las guardas, y contrastar con una quinta señal que no está en el código sino en las conversaciones del equipo.

🗂️

Colecciones por clave

El contexto contiene un mapa o un diccionario indexado por identificador. La máquina dejó de modelar un flujo para modelar un almacén de varios recursos a la vez.

⏱️

Marcas de tiempo

Aparecen campos con instantes y comparaciones contra un umbral. La frescura es vocabulario de cache, y su presencia en un grafo de estados es un préstamo del dominio equivocado.

🧹

Acciones de invalidación

Existen acciones cuyo único cometido es borrar o marcar entradas obsoletas. Estás manteniendo coherencia entre copias, que es justo el trabajo del que la cache te libraba.

👯

Copias que divergen

Dos partes de la pantalla muestran versiones distintas del mismo recurso porque cada una lo obtuvo por su vía. El fallo es silencioso: nadie lanza un error, solo hay dos verdades.

La quinta señal es la más fiable y no vive en el repositorio: es la frase alguien tiene que refrescar la máquina cuando cambie ese dato. En cuanto un equipo necesita coordinar manualmente que un cambio en un sitio se refleje en un grafo que guardó una copia, la arquitectura ya está pidiendo a gritos una fuente única con invalidación por clave. Esa coordinación manual no escala porque crece de forma cuadrática: cada dato nuevo debe conectarse con cada máquina que lo hubiera copiado, y nadie mantiene esa matriz en la cabeza más allá de la tercera pantalla.

📝
La excepción legítima: la instantánea con significado

No todo dato de servidor en el contexto es el anti-patrón. Hay valores que el flujo debe congelar porque el dominio lo exige: el precio que se le mostró al usuario cuando aceptó, la versión del documento sobre la que se está editando, el saldo comprobado en el instante de autorizar. Ahí el valor no es una copia acelerada de la cache, es una instantánea con consecuencias contractuales, y actualizarla sola sería un fallo grave. La prueba para distinguirlas es directa: si te preocupara que ese valor quedara viejo, era cache; si te preocuparía que cambiase sin que el usuario lo apruebe, era instantánea y su sitio es el contexto.

Por qué la cache casera pierde siempre

Conviene entender que la derrota no es por falta de talento ni de tiempo, sino por posición en la arquitectura. Una cache necesita ver todos los consumidores de un recurso para deduplicar, compartir e invalidar; el contexto de una máquina, por diseño, solo ve lo que ocurre dentro de ese actor. La limitación es estructural y ninguna cantidad de código la levanta.

Capacidad Cache dedicada Cache en el contexto
Deduplicar peticiones simultáneas por clave, entre todos los consumidores imposible fuera del actor
Compartir el dato entre pantallas inmediato para quien conozca la clave requiere props o mensajes a mano
Revalidar en foco o reconexión política declarativa temporizadores propios
Recolectar lo que ya nadie mira por tiempo de inactividad vive mientras viva el actor
Sobrevivir a la navegación sí, es externa a la vista no, muere con la máquina
Inspeccionar el estado del dato herramientas dedicadas leyendo el contexto a mano

Hay además un coste que la tabla no recoge y que suele decidir el debate: la carga cognitiva compuesta. Quien lee la máquina para entender el flujo tiene que atravesar la lógica de frescura, y quien la lee para entender la frescura tiene que atravesar el grafo. Dos preocupaciones entrelazadas cuestan más que la suma de las dos separadas, y esa multiplicación se paga en cada revisión de código durante toda la vida del módulo.

flowchart TD
A[el contexto guarda un dato de servidor] --> B[el dato lo consumen otros sitios?]
B -->|si| C[es cache: sacalo a la capa de datos]
B -->|no| D[te preocuparia que quedase obsoleto?]
D -->|si| C
D -->|no| E[cambiarlo sin permiso romperia el dominio?]
E -->|si| F[es una instantanea: se queda en el contexto]
E -->|no| G[es un derivado: calculalo al vuelo]
style C fill:#f38ba8,color:#11111b
style F fill:#a6e3a1,color:#11111b
style G fill:#89dceb,color:#11111b

El refactor: devolver el dato a su dueño

La cura tiene tres movimientos y se puede aplicar sin detener el desarrollo. Primero, extrae la clave: convierte el mapa del contexto en un identificador y una función que construya la clave del recurso, sin tocar todavía nada más. Segundo, delega la lectura: sustituye el estado transitorio que consultaba la frescura por un actor que llama a la cache, porque esa decisión ya la toma ella y hacerlo dos veces solo produce discrepancias. Tercero, adelgaza el contexto: elimina las copias, deja los identificadores, las instantáneas con significado y los derivados mínimos que el flujo necesita para sus guardas.

type Contexto = {
  fichaId: string
  precioAceptado: number | null
  intentos: number
}

const leerFicha = fromPromise(({ input, signal }: { input: { id: string }; signal: AbortSignal }) =>
  qc.fetchQuery({
    queryKey: ['ficha', input.id],
    queryFn: () => fetch(`/api/fichas/${input.id}`, { signal }).then((r) => r.json()),
    staleTime: 60_000,
  })
)

El contexto resultante se lee en tres líneas y cada campo tiene una justificación distinta e irreductible: fichaId es identidad del flujo, precioAceptado es una instantánea con consecuencias, intentos es historia del procedimiento. Ninguno es una copia de algo que viva mejor en otro sitio, y esa es toda la prueba que el refactor necesita pasar. La guarda de frescura desaparece porque staleTime la expresa donde corresponde, y el estado transitorio que la consultaba se puede borrar entero.

Toda capa que guarda un dato ajeno hereda la obligación de mantenerlo vivo

La razón profunda por la que este anti-patrón reaparece en todos los equipos y en todas las tecnologías es que copiar un dato parece una operación gratuita y en realidad es la adquisición de una deuda perpetua. En el instante en que tu módulo guarda una copia de algo cuya verdad vive en otra parte, hereda tres obligaciones que antes no tenía: saber cuándo la fuente cambió, decidir qué hacer con su copia cuando eso ocurra, y responder por la incoherencia mientras tanto. Esas obligaciones no aparecen en el diff de la línea que hizo la copia —por eso la copia siempre parece barata— pero se cobran durante años, en forma de errores que solo se manifiestan con dos pestañas abiertas, dos usuarios simultáneos o una navegación en el orden inusual. Una máquina de estado es especialmente vulnerable a esta trampa porque su contexto es cómodo, está a mano y ya lo estás tocando; pero su vocación es otra: describir qué es legal hacer ahora, no qué es cierto ahora. Cuando confundes ambas vocaciones, el grafo deja de ser una especificación del protocolo y se convierte en una base de datos con opiniones sobre el tiempo, y pierdes justo lo que hacía valiosa la máquina: que se pudiera leer entera y creer lo que dice. Pasarte de listo, en este dominio, no significa escribir código complicado; significa aceptar una responsabilidad que pertenecía a otra capa porque en ese momento parecía más rápido que pedírsela. La disciplina que este nivel entero defiende cabe en una regla: cada dato tiene un dueño, y el resto del sistema solo debe conocer su nombre.

⚔️ Desmonta la cache que no debía estar ahí
  1. Busca en tus máquinas los cuatro rasgos del diagnóstico —mapas por clave, marcas de tiempo, acciones de invalidación y copias divergentes— y anota dónde aparecen.
  2. Recorre cada campo del contexto y clasifícalo con el diagrama: cache, instantánea o derivado.
  3. Comprueba con una navegación de ida y vuelta cuánto sobrevive de tu cache casera y qué se vuelve a pedir de cero.
  4. Aplica el primer movimiento del refactor: sustituye el mapa por un identificador y una función que construya la clave.
  5. Elimina la guarda de frescura delegando en el staleTime de la cache y borra el estado transitorio que la consultaba.
  6. Escribe una frase por cada campo que quede en el contexto justificando por qué su dueño es el flujo y no la capa de datos.