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.
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.v1evento.cata_creada.v1evento.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.