Email copiado — support@tuurt.com
Cargando experiencia
azure · 17 de septiembre de 2026 · 7 min

Durable Functions en Azure: cómo modelar un flujo de aprobación de varios pasos sin perder el estado

Durable Functions persiste el estado de un flujo de aprobación de varios días mediante replay sobre una historia de eventos, sin que el desarrollador escriba una tabla de estado a mano.

Por Equipo Tuurt

Durable Functions en Azure: cómo modelar un flujo de aprobación de varios pasos sin perder el estado

Un flujo de aprobación parece simple hasta que hay que implementarlo de verdad. Alguien sube una solicitud, un responsable tiene que revisarla, puede tardar minutos o puede tardar tres días, y mientras tanto el sistema tiene que recordar en qué paso quedó, qué datos llevaba la solicitud original y qué hacer según la respuesta que llegue. Si la aprobación no llega en un plazo razonable, hay que escalarla. Nada de esto es raro: es la forma habitual de modelar cualquier proceso de negocio con espera humana en el medio. Lo raro es que implementarlo bien, sin perder estado si el proceso se reinicia o escala a otra instancia, termina siendo más trabajo de infraestructura que de lógica de negocio.

La respuesta obvia es guardar el estado en una tabla: estado_solicitud, paso_actual, fecha_limite, y un cron o un trigger que revisa esa tabla para decidir qué hacer a continuación. Funciona, pero el desarrollador termina escribiendo una máquina de estados a mano, con todo lo que eso implica: qué pasa si dos triggers se disparan casi al mismo tiempo sobre la misma fila, cómo se recupera el proceso si el worker que lo estaba ejecutando muere a la mitad, cómo se expresa una espera de "hasta 3 días o hasta que llegue la respuesta, lo que ocurra primero" sin un polling constante. Durable Functions, la extensión de Azure Functions para orquestación de flujos de larga duración, existe para quitar ese trabajo de encima.

El mecanismo: replay sobre una historia de eventos

Lo que hace distinta a una orchestrator function del resto de Azure Functions es cómo se ejecuta. El runtime no mantiene el método vivo en memoria durante los tres días que puede durar una aprobación. En cambio, cada vez que hay algo nuevo que procesar -una activity que terminó, un evento externo que llegó, un timer que venció- el runtime vuelve a ejecutar el método orquestador desde la primera línea. La diferencia frente a ejecutarlo de cero es que, durante ese re-recorrido, cada await sobre una tarea que ya se completó en una ejecución anterior no vuelve a ejecutar esa tarea: el runtime la reconoce, consulta la historia de eventos persistida de la orquestación y le devuelve inmediatamente el resultado que ya había quedado registrado. El código solo avanza hasta el primer punto donde encuentra algo que de verdad no se ha completado todavía, y ahí se detiene otra vez a esperar.

Esa historia de eventos es justamente lo que reemplaza a la tabla de estado que se escribiría a mano. Cada llamada a una activity function, cada evento externo recibido, cada timer creado queda registrado como un evento en esa historia, y el runtime la persiste en el almacenamiento configurado para la extensión. El desarrollador no diseña el esquema de esa persistencia ni escribe el código que la lee y la escribe: escribe una función que parece secuencial y normal, y el mecanismo de replay es lo que le da continuidad a través de reinicios, escalado horizontal o fallos del proceso.

El flujo de aprobación

Para modelar la espera de una aprobación humana con plazo, se combinan dos primitivas de Durable Functions: WaitForExternalEvent, que suspende la orquestación hasta que llega un evento con un nombre determinado, y CreateTimer, que crea una pausa durable hasta una fecha concreta. Envolver ambas en un Task.WhenAny da exactamente la semántica que hace falta: continuar en cuanto ocurra lo primero, sea la aprobación o el vencimiento del plazo.

[Function(nameof(FlujoAprobacion))]
public static async Task<string> FlujoAprobacion(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var solicitud = context.GetInput<SolicitudInput>();

    await context.CallActivityAsync(nameof(NotificarResponsable), solicitud);

    using var cts = new CancellationTokenSource();
    Task<AprobacionResult> tareaAprobacion =
        context.WaitForExternalEvent<AprobacionResult>("AprobacionRecibida");
    Task tareaTimeout = context.CreateTimer(
        context.CurrentUtcDateTime.AddDays(3), cts.Token);

    Task completada = await Task.WhenAny(tareaAprobacion, tareaTimeout);

    if (completada == tareaAprobacion)
    {
        cts.Cancel();
        var resultado = tareaAprobacion.Result;
        await context.CallActivityAsync(
            resultado.Aprobado ? nameof(ProcesarAprobacion) : nameof(ProcesarRechazo),
            solicitud);
        return resultado.Aprobado ? "aprobado" : "rechazado";
    }

    await context.CallActivityAsync(nameof(EscalarSolicitud), solicitud);
    return "escalado_por_timeout";
}

El punto que suele sorprender la primera vez es context.CurrentUtcDateTime. No es un detalle cosmético: la orquestación se re-ejecuta desde el principio en cada replay, y si esa línea usara DateTime.UtcNow directamente, cada replay calcularía un vencimiento distinto -tres días desde el momento del replay, no desde el momento original en que se creó el timer-, rompiendo por completo la garantía que se busca. CurrentUtcDateTime es la hora que el runtime registró la primera vez que se ejecutó ese punto del código, y la devuelve sin cambios en cada replay posterior. Esta es la regla general del modelo de programación de Durable Functions: el código de un orquestador tiene que ser determinista. Nada de DateTime.Now, nada de Guid.NewGuid() sin pasar por las utilidades que ofrece el propio context, nada de llamadas HTTP o de acceso a base de datos directamente en el cuerpo del orquestador. Cualquier efecto secundario o cualquier fuente de no-determinismo tiene que vivir en una activity function, nunca en el orquestador mismo.

El evento externo se envía desde afuera con el cliente de orquestación, típicamente desde el endpoint HTTP que recibe la respuesta del responsable:

[Function("RecibirAprobacion")]
public static async Task<HttpResponseData> RecibirAprobacion(
    [HttpTrigger(AuthorizationLevel.Function, "post", Route = "aprobaciones/{instanceId}")]
    HttpRequestData req,
    string instanceId,
    [DurableClient] DurableTaskClient client)
{
    var body = await req.ReadFromJsonAsync<AprobacionResult>();
    await client.RaiseEventAsync(instanceId, "AprobacionRecibida", body);
    return req.CreateResponse(System.Net.HttpStatusCode.Accepted);
}

RaiseEventAsync no busca ni reconstruye estado manualmente: solo necesita el instanceId de la orquestación, que se generó cuando se arrancó y normalmente se guarda junto a la solicitud para poder referenciarla después.

Lo que no funcionó: notificar dentro del orquestador

La primera versión de este flujo llamaba directamente a un servicio de envío de correo desde dentro del método orquestador, sin pasar por una activity function, razonando que "solo es notificar, no cambia el estado de nada". El resultado fueron notificaciones duplicadas en producción, y no de forma aleatoria: pasaba cada vez que la orquestación tenía que hacer replay por cualquier motivo -un reinicio del host, un evento externo que llegaba mientras la instancia todavía estaba procesando el timer- porque cada replay volvía a ejecutar esa línea del código desde el principio, y al no ser una activity function, el runtime no tenía forma de saber que ese envío ya había ocurrido y de omitirlo. La activity function no es solo una forma de organizar el código: es la unidad que el runtime registra en la historia de eventos y cuyo resultado reutiliza en replays posteriores en lugar de volver a ejecutarla. Cualquier llamada con efecto secundario que quede fuera de una activity se ejecuta una vez por cada replay, no una vez por cada ocurrencia real.

La corrección fue mover el envío a una activity (NotificarResponsable en el ejemplo de arriba) y no dejar ningún efecto secundario suelto en el cuerpo del orquestador. Vale la pena aclarar que esto no elimina por completo la posibilidad de una notificación duplicada: si la propia activity falla después de enviar el correo pero antes de que el runtime registre que terminó, Durable Functions puede reintentarla, y ahí sí puede repetirse el envío. Esa es una garantía de "al menos una vez" a nivel de activity, distinta del problema del orquestador, y si el efecto secundario no tolera duplicados de verdad, la activity necesita ser idempotente por su cuenta -por ejemplo, verificando antes de enviar si ya existe un registro de notificación para esa solicitud- en vez de asumir que el framework se encarga de eso por completo.

Cuándo tiene sentido este modelo

Durable Functions no sustituye una cola de mensajes ni un motor de reglas de negocio complejo, y forzar cualquier flujo asíncrono a este patrón añade una capa de restricciones -determinismo del orquestador, todo efecto secundario en una activity- que no siempre vale la pena. Donde sí encaja bien es exactamente en el caso que motivó este artículo: un proceso con varios pasos, posiblemente con espera humana en el medio, que puede durar de minutos a días, donde perder el punto exacto en el que iba el proceso si el host se reinicia no es aceptable. En ese escenario, la alternativa de escribir a mano la tabla de estado, el mecanismo de espera con timeout y la recuperación ante fallos del proceso es exactamente el trabajo que el modelo de orquestación con replay resuelve por debajo, dejando que el código que se escribe se lea como lo que realmente es: una secuencia de pasos con una espera en el medio.

azure durable-functions arquitectura dotnet
← Volver al blog