wandres.dev
ACTORES DISTRIBUIDOS · más allá de una máquina

Diseñar con actores: granularidad, identidad y un actor por entidad

Un actor es un punto de serialización, y esa única propiedad decide casi todo el diseño. Esta lección identifica la frontera del actor con la frontera de consistencia y la equipara al agregado del diseño dirigido por el dominio, deriva la granularidad desde las invariantes que deben cumplirse de forma atómica en lugar de desde el organigrama del modelo de datos, explica por qué la identidad de un actor es simultáneamente clave de enrutado, de colocación, de persistencia y de idempotencia, enumera las señales que hacen correcto o ruinoso el patrón de un actor por entidad —incluida la clave caliente— y disecciona los tres antipatrones que aparecen siempre: el actor dios, la cadena síncrona disfrazada y el actor anémico.

⏱ 23 min

Después de tres lecciones de teoría queda la pregunta que decide si un sistema de actores será una arquitectura o un disfraz: dónde se ponen las fronteras. La respuesta no sale de la intuición ni del modelo de datos, sale de una propiedad estructural que ya conoces y que casi nadie explota como criterio de diseño. Un actor procesa un mensaje cada vez, luego un actor es un punto de serialización, luego todo lo que un actor encierra queda automáticamente protegido por exclusión mutua y todo lo que queda fuera pierde esa protección para siempre. Elegir la granularidad es, por tanto, elegir qué invariantes vas a poder mantener sin esfuerzo y cuáles vas a tener que defender con protocolos. Es la misma decisión que en el diseño dirigido por el dominio se llama trazar el agregado, y no es una coincidencia.

🎯 Al terminar esta lección sabrás
  • Identificar la frontera del actor con la frontera de consistencia y derivarla de las invariantes.
  • Diseñar la identidad sabiendo que sirve de clave de enrutado, persistencia e idempotencia.
  • Decidir con criterio cuándo un actor por entidad es correcto y cuándo se convierte en lastre.
  • Detectar el actor dios, la cadena síncrona disfrazada y el actor anémico antes de pagarlos.

La frontera del actor es la frontera de consistencia

Empieza por las invariantes, no por las entidades. Escribe las afirmaciones que deben ser ciertas en todo momento sin excepción —el saldo nunca queda negativo, no se venden dos asientos con el mismo número, el pedido confirmado tiene al menos una línea— y observa qué datos toca cada una. Todo lo que participa en una misma invariante atómica tiene que caber dentro de un mismo actor, porque el turno es el único lugar donde la atomicidad es gratuita. Y todo lo que no participa debe quedar fuera, porque meterlo dentro no compra nada y sí cuesta contención.

La regla inversa es igual de operativa y menos evidente: si dos datos están en el mismo actor pero ninguna invariante los relaciona, ese actor está serializando trabajo sin motivo. Cada mensaje que llega por uno de los dos motivos hace esperar a los del otro, y el rendimiento máximo de ambos queda atado al peor de los dos. Un actor demasiado grande no falla por consumo de memoria, falla por convertirse en un cuello de botella que además concentra riesgo: cuando muera, morirá con todo lo que custodiaba.

flowchart TD
subgraph Agregado
  P[pedido] --- L1[linea 1]
  P --- L2[linea 2]
end
C[catalogo] -. consistencia eventual .-> P
E[envio] -. consistencia eventual .-> P
P -- invariante atomica --> L1
P -- invariante atomica --> L2
style P fill:#cba6f7,color:#11111b
style C fill:#89b4fa,color:#11111b
style E fill:#89b4fa,color:#11111b

Aceptar esa frontera obliga a aceptar su reverso, y es aquí donde muchos equipos se detienen demasiado tarde: entre actores no hay transacciones. Ninguna. Una operación que afecte a dos actores es, por construcción, un protocolo de varios pasos con estados intermedios visibles, compensaciones para deshacer lo que ya se hizo y la posibilidad de quedarse a medias si alguien muere en el peor instante. Eso tiene un nombre —proceso de negocio de larga duración, gestor de proceso, saga— y tiene una forma natural: otro actor, cuyo estado es precisamente en qué punto de la conversación estamos.

import { setup, assign } from 'xstate'

// El gestor de proceso: su estado ES el punto de la conversacion,
// y cada camino de fallo tiene su compensacion declarada.
export const reserva = setup({
  types: {
    context: {} as { asiento: boolean; pago: boolean },
    events: {} as
      | { type: 'ASIENTO_OK' }
      | { type: 'ASIENTO_FALLO' }
      | { type: 'ASIENTO_LIBERADO' }
      | { type: 'PAGO_OK' }
      | { type: 'PAGO_FALLO' },
  },
}).createMachine({
  initial: 'reservandoAsiento',
  context: { asiento: false, pago: false },
  states: {
    reservandoAsiento: {
      on: {
        ASIENTO_OK: { target: 'cobrando', actions: assign({ asiento: true }) },
        ASIENTO_FALLO: 'rechazada',
      },
    },
    cobrando: {
      on: {
        PAGO_OK: { target: 'confirmada', actions: assign({ pago: true }) },
        PAGO_FALLO: 'liberandoAsiento',
      },
    },
    liberandoAsiento: { on: { ASIENTO_LIBERADO: 'rechazada' } },
    confirmada: { type: 'final' },
    rechazada: { type: 'final' },
  },
})

Ese grafo diminuto es la prueba de que renunciar a la transacción no equivale a renunciar al rigor. Lo que en una base de datos era invisible —el instante en que el asiento ya está tomado pero el cobro aún no ha ocurrido— aquí tiene nombre, es observable y admite una política explícita si el proceso se interrumpe justo ahí. La consistencia entre agregados no se pierde, se hace material.

Identidad: la dirección como clave de dominio

La identidad de un actor parece un asunto de nomenclatura y es una de las decisiones más cargadas del diseño, porque una sola cadena de texto acaba cumpliendo cuatro funciones a la vez.

🧭

Clave de enrutado

Determina a quién llega el mensaje. Si la identidad es derivable de los datos del mensaje, cualquiera puede dirigirse a la entidad correcta sin consultar a nadie.

📍

Clave de colocación

Determina en qué nodo vive el actor y, por tanto, cómo se reparte la carga. Identidades mal distribuidas producen particiones desiguales.

💾

Clave de persistencia

Es el nombre bajo el que se guarda y se recupera el estado. Cambiarla más adelante es una migración de datos, no un renombrado.

🧾

Ámbito de idempotencia

Define dentro de qué frontera un identificador de operación puede considerarse duplicado. Fuera de esa frontera, el duplicado es invisible.

De esas cuatro funciones se deduce la recomendación práctica: haz que la identidad se derive de una clave estable del dominio y no de un identificador generado al azar. Una identidad derivable regala la semántica de obtener o crear —el primer mensaje dirigido a una entidad que aún no existe la trae al mundo— y elimina la necesidad de un actor directorio que traduzca claves en direcciones. Ese directorio, cuando hace falta, es un salto de red más en cada operación y, peor aún, un punto de serialización global que reproduce en el sistema entero el problema que el diseño estaba intentando repartir.

// La identidad como funcion pura del dominio: sin directorio, sin sorpresas.
type ClaveActor = `${string}:${string}`

const claveDe = (tipo: string, id: string): ClaveActor => `${tipo}:${id}`

// Obtener o crear se vuelve trivial porque la direccion es calculable.
function dirigir(mensaje: { tipo: string; entidad: string }): ClaveActor {
  return claveDe(mensaje.tipo, mensaje.entidad)
}
⚠️
La clave caliente rompe el patron sin previo aviso

Un actor por entidad reparte la carga de forma perfecta mientras las entidades reciban tráfico parecido, y se desmorona en cuanto una recibe un tráfico desproporcionado, porque toda esa carga cae sobre un único punto de serialización que no se puede replicar. El escenario es común: el artículo que se hace viral, la cuenta corporativa con cien mil usuarios, el evento con entradas agotándose. Las tres salidas conocidas son repartir las lecturas en copias que aceptan datos ligeramente atrasados, dividir la entidad en subactores cuando la operación es conmutativa y se puede agregar después, y sacar del camino de escritura todo lo que no forma parte de una invariante. Ninguna es gratis, y por eso conviene identificar las entidades candidatas a calentarse antes de escribir el primer actor.

Cuándo un actor por entidad y cuándo no

El patrón de un actor por entidad tiene una fuerza enorme y un rango de aplicación más estrecho de lo que sugiere su elegancia. La tabla que sigue recoge las señales que he visto decidir el asunto en la práctica.

Señal A favor de un actor por entidad En contra
Protocolo la entidad tiene ciclo de vida y estados con nombre es un registro que solo se lee y se escribe
Contención varias operaciones compiten por la misma entidad cada operación toca entidades distintas
Estado caliente conviene mantener datos en memoria entre operaciones el estado es enorme y se accede muy de tarde en tarde
Tiempo propio la entidad necesita temporizadores o recordatorios suyos no hay nada que vencer ni que reintentar
Consultas siempre se accede por identidad conocida hacen falta búsquedas por atributos o agregaciones
Cardinalidad millones de entidades con tráfico repartido unas pocas entidades con tráfico muy desigual

La fila de las consultas merece un párrafo porque es donde más proyectos tropiezan. Un sistema de actores responde de maravilla a la pregunta qué le pasa a esta entidad y muy mal a la pregunta cuántas entidades cumplen tal condición, porque esa segunda pregunta exige recorrer muchos buzones, no tiene una vista transaccional coherente y produce un resultado que ya era viejo cuando terminó de componerse. La solución no es forzar el modelo sino admitir que hay dos caminos distintos: los actores llevan el camino de escritura, con sus invariantes y su consistencia fuerte por entidad, y las consultas se sirven desde proyecciones construidas a partir de los eventos que los actores publican. Esa separación no es un patrón exótico añadido, es la consecuencia directa de haber elegido una frontera de consistencia pequeña.

Antipatrones: dios, cadena y anemia

El actor dios es el más frecuente y el más fácil de justificar mientras se escribe. Nace de la comodidad de tener todo el estado en un sitio y termina siendo el equivalente exacto de un candado global, con la diferencia de que ahora es un candado que además guarda datos y que, cuando falla, se lleva el sistema entero. La señal temprana es un actor cuyo protocolo tiene decenas de mensajes sin ninguna relación entre sí; si al leer su lista de eventos no puedes enunciar una invariante que los una, no es un actor, es un almacén con buzón.

La cadena síncrona disfrazada es más sutil y más peligrosa. Ocurre cuando cada actor, al recibir un mensaje, pregunta a otro y espera su respuesta antes de continuar, de modo que una operación recorre cinco actores encadenados y su latencia es la suma de todas las esperas. Se ha reinventado la llamada síncrona con más ceremonia y sin sus ventajas, y se ha añadido una forma nueva de bloqueo: dos actores que se esperan mutuamente no se desbloquean nunca, porque el mensaje que liberaría a cada uno está en el buzón que el otro no está atendiendo. El remedio es diseñar la conversación en lugar de la llamada: quien inicia entrega su dirección, cada participante avanza sin esperar y el estado de la operación vive en un actor de proceso que va recibiendo confirmaciones.

📝
En la interfaz, la granularidad la marca el ciclo de vida

Dentro del navegador el criterio de las invariantes se traduce casi sin pérdida: un actor por fila editable, por formulario, por conexión abierta o por elemento con estado propio, porque cada uno tiene un ciclo de vida distinto y un conjunto de eventos que solo a él le conciernen. La señal de que la granularidad se quedó gruesa es una máquina con estados paralelos que no interactúan entre sí, y la señal de que se quedó fina es una cascada de eventos que solo sirven para que un hijo le cuente al padre lo que el padre ya podía deducir.

El actor anémico cierra la lista. Es el actor que no tiene protocolo, solo dos mensajes para leer y escribir un dato, y que por tanto paga íntegro el coste del modelo —serialización, indirección, asincronía, ciclo de vida— sin cobrar ninguna de sus ventajas, porque no hay invariante que proteger ni comportamiento que designar. Si un actor podría sustituirse por una entrada en un mapa sin perder ninguna garantía, sustitúyelo.

La granularidad no es una decision de rendimiento: es la declaracion de que puedes prometer

Se suele presentar la elección del tamaño de un actor como un problema de ajuste, un dial entre demasiado fino y demasiado grueso que se calibra midiendo. Esa lectura es cierta y superficial. Lo que de verdad se decide al trazar la frontera de un actor es el conjunto de afirmaciones que tu sistema podrá sostener sin condiciones, porque dentro de esa frontera la atomicidad es un regalo del turno y fuera de ella toda garantía hay que construirla con protocolo, compensación y tiempo. Por eso la granularidad no debería derivarse del modelo de datos, ni del organigrama del equipo, ni de una estimación de carga que aún no tienes: debería derivarse de la lista de invariantes que el negocio no admite violar ni un instante. Escribe esa lista antes que el código y descubrirás que es corta, mucho más corta de lo que la base de datos sugiere, y que casi todo lo que creías que debía ser consistente en el acto tolera perfectamente unos segundos de desfase si el protocolo es honesto al respecto. Esa poda es el verdadero trabajo de arquitectura, y el modelo de actores es valioso precisamente porque no te deja evitarla: al no ofrecerte transacciones entre actores, te obliga a decidir explícitamente dónde termina lo indivisible, mientras que una base de datos con transacciones globales te permite posponer esa decisión durante años hasta que la escala la toma por ti de la peor manera. Un sistema de actores bien granulado se reconoce en que cada frontera se puede justificar con una frase del dominio y no con una explicación técnica, en que las conversaciones entre actores tienen estados con nombre en lugar de esperas escondidas, y en que nadie del equipo necesita saber cuántos nodos hay para razonar sobre la corrección. Cuando el diseño llega a ese punto, la escala deja de ser un problema de arquitectura y pasa a ser lo que siempre debió ser: un asunto de capacidad.

⚔️ Traza tus fronteras
  1. Escribe la lista completa de invariantes de tu dominio que no admiten ni un instante de violación y comprueba cuántas son de verdad.
  2. Agrupa los datos por invariante y propón una granularidad de actores derivada de esa agrupación, no del esquema de tablas.
  3. Identifica dos entidades candidatas a convertirse en clave caliente y decide de antemano cuál de las tres mitigaciones aplicarías.
  4. Diseña la identidad de tus actores como función pura del dominio y verifica que obtener o crear funciona sin ningún directorio.
  5. Busca en tu sistema una operación que cruce dos fronteras y reescríbela como conversación con actor de proceso y compensaciones.
  6. Encuentra un actor anémico y otro con vocación de actor dios, y argumenta con invariantes por qué el primero sobra y el segundo hay que partir.