Entrada

Arquitectura de Engagement basada en reglas: así diseñé un orquestador serverless para campañas en Enolisa

Un vistazo técnico al orquestador de engagement de Enolisa: un sistema totalmente serverless, modular y basado en eventos que coordina reglas, estados de usuario y notificaciones push.

Arquitectura de Engagement basada en reglas: así diseñé un orquestador serverless para campañas en Enolisa

Una necesidad clara: engagement sin caos

En Enolisa, mi objetivo no era simplemente enviar push.
Quería un sistema de engagement completo, capaz de tomar decisiones basadas en reglas, estado del usuario, plantillas dinámicas y timing preciso. Y, además, quería que fuera serverless, escalable y fácil de extender.

La solución terminó siendo un pequeño “ecosistema” formado por:

  • Cloud Run (dos servicios: orquestador y ejecutor),
  • Pub/Sub como columna vertebral de eventos,
  • Firebase (Firestore, RTDB y FCM),
  • un conjunto de schemas JSON que definen reglas y acciones,
  • y una arquitectura 100% orientada a eventos y decisiones basadas en contexto.

En este artículo cuento cómo está montado el orquestador y por qué funciona.


Arquitectura general: decisiones arriba, ejecución abajo

En el diseño distinguí dos piezas muy claras:

🧠 1. Orquestador (Cloud Run)

Recibe un evento, interpreta reglas, evalúa el estado del usuario y decide qué acción tiene que ocurrir.

🛠️ 2. Ejecutor (Cloud Run)

Recibe una acción ya decidida y la lleva a cabo (por ejemplo, enviar una push a FCM), garantizando idempotencia y registros.

Esta separación fue clave:

El orquestador piensa, el ejecutor actúa.

Con esto evito mezclar lógica de negocio con ejecución y mantengo un diseño muy limpio: si mañana quiero añadir email, SMS, badges internos o cualquier otro canal, solo tengo que añadir nuevos ejecutores.


Las reglas: todo empieza en los JSON

Algo que tuve claro desde el principio es que no quería tener reglas “hardcodeadas” en el backend.

Así que las reglas viven en ficheros como reglas.schema.json.
Ahí defino cosas como:

  • condiciones por evento,
  • segmentación por estado,
  • thresholds temporales,
  • plantillas,
  • y, por supuesto, deeplinks.

Ejemplo (simplificado):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "regla_id": "CTA_COMPLETAR_CATA",
  "evento": "evento.vino_creado.v1",
  "condiciones": {
    "tiene_cata_pendiente": true
  },
  "acciones": [
    {
      "tipo": "accion.push.v1",
      "titulo": "Completa tu cata",
      "mensaje": "Registra tu experiencia con {VINO_NOMBRE}",
      "deeplink": "enolisa://catas/pending?vino_id={VINO_ID}"
    }
  ]
}

Esto hace que el orquestador pueda evolucionar sin tener que recompilar nada.


Eventos: lo que mueve todo

La app genera eventos claros:

  • evento.vino_creado.v1
  • evento.cata_creada.v1
  • evento.barrido_programado.v1
  • etc.

Cada evento viaja por Pub/Sub hacia el orquestador. Esto hace que el sistema reaccione de forma totalmente asíncrona y desacoplada.

Código real del orquestador (simplificado, pero fiel a lo que tengo):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.post("/events", async (req, res) => {
  const evento = req.body;

  const reglas = await cargarReglas();
  const coincidencias = reglas.filter(r => r.evento === evento.tipo);

  for (const regla of coincidencias) {
    const pasa = await evaluarRegla(regla, evento);
    if (pasa) {
      const accion = construirAccion(regla, evento);
      await publicarAccion(accion);
    }
  }

  res.json({ ok: true });
});

Todo es modular, todo se decide en base al schema.


Construyendo la acción: el contrato es el rey

El orquestador no envía pushes directamente. Envía acciones, que siguen el schema accion.push.v1.schema.json.

Eso hace que haya un contrato claro entre el orquestador y el ejecutor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "tipo": "accion.push.v1",
  "version": "v1",
  "uid": "bNnWFjzQvITY8T********",
  "token_fcm": "...",
  "idioma": "es",
  "plantilla_id": "CTA_COMPLETAR_CATA",
  "payload": {
    "titulo": "Completa tu cata",
    "mensaje": "Registra tu experiencia con...",
    "deeplink": "enolisa://catas/pending?vino_id=...",
    "data": {
      "vino_id": "...",
      "plantilla_id": "CTA_COMPLETAR_CATA"
    }
  },
  "idempotency_key": "...."
}

Aquí ya entra la magia:

  • El deeplink va en payload.deeplink
  • Datos adicionales van en payload.data
  • La plantilla decide todo lo visual

Executor: simple, robusto y logueado

El ejecutor recibe la acción como una petición HTTP (protegida con autenticación de Cloud Tasks) y dispara la push vía FCM HTTP v1.

Parte real del código:

1
2
3
4
5
6
7
8
9
10
11
12
export async function sendFcm(action: AccionPushV1) {
  const payload = buildFcmPayload(action);

  const url = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`;

  const response = await firebaseClient.post(url, payload);

  return {
    ok: true,
    response: response.data
  };
}

Y el buildFcmPayload (también real):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const data: Record<string, string> = {
  deeplink: action.payload.deeplink ?? "",
  ...(action.payload.data || {})
};

return {
  message: {
    token: action.token_fcm,
    notification: {
      title: action.payload.titulo,
      body: action.payload.mensaje
    },
    data
  }
};

El ejecutor también registra:

  • latencia,
  • errores permanentes,
  • errores temporales,
  • y guarda una auditoría en Firestore.

Es totalmente idempotente (gracias a la idempotency_key que viaja en la acción).


Serverless de verdad

Toda la arquitectura es autoscaling, pay-per-use y sin servidores:

Pieza Tecnología Rol
Orquestador Cloud Run Recibe eventos y decide acciones
Ejecutor Cloud Run Ejecuta la acción (push, email, etc.)
Mensajería Pub/Sub Pasa eventos y acciones
Estado y reglas Firestore + JSON Estado del usuario + configuración
Push delivery FCM HTTP v1 Entrega en Android/iOS

Ventajas claras:

  • Escala automáticamente
  • Cambias reglas sin desplegar
  • Cada pieza tiene responsabilidad única
  • Puedes añadir nuevos canales sin romper nada

¿Por qué serverless y no un monolito?

Podría haberlo hecho “todo en uno” con una Cloud Function gigante. Pero no quería eso.

La separación orquestador/ejecutor tiene tres beneficios reales:

1. Seguridad y aislamiento

El ejecutor no sabe nada de reglas ni de negocio. Solo recibe acciones y las ejecuta.

2. Evolución sin miedo

Si mañana quiero añadir:

  • un canal de email,
  • webhooks,
  • SMS,
  • badges internos,

solo tengo que añadir otro ejecutor.

3. Observabilidad modular

Cada pieza loguea lo suyo, cada fallo está acotado.


Reflexión final

En un proyecto como Enolisa, donde la personalización y el engagement son parte clave de la experiencia, necesitaba una arquitectura que fuera:

  • modular,
  • basada en eventos,
  • reglada por JSON,
  • totalmente escalable,
  • y, sobre todo, controlable.
Esta entrada está licenciada bajo CC BY 4.0 por el autor.