Cuatro emails idénticos
Te voy a contar un bug que probablemente ya viste, o que vas a ver. Es un clásico, y casi siempre termina con el mismo aprendizaje: necesitabas un Worker Service en .NET desde el día uno y nadie lo planteó.
Imagínate un API de e-commerce. Endpoint POST /orders que recibe un pedido, lo guarda y publica un mensaje a Azure Service Bus para que se mande el email de confirmación al cliente. Hasta ahí, normal.
Lo siguiente también es muy normal, y ahí está el problema: el equipo decidió que el consumidor de esa cola (el código que efectivamente envía el email) vivía dentro del mismo API, como un IHostedService. Misma solución, mismo deploy, todo junto.
Funcionó perfecto. Durante meses. Con una sola réplica.
Llegó Black Friday, escalaron el API a cuatro réplicas para soportar el tráfico, y el cliente recibió cuatro emails idénticos por cada compra. Mismo timestamp, mismo contenido, mismo número de orden. Cuatro veces.
Soporte explotó. Los devs entraron a investigar pensando que había un bug en el código del email. Y no. El código del email era correcto. Lo que estaba mal era una decisión que nadie revisó cuando se tomó: dónde corre ese código.
La pregunta que casi nadie hace
Cuando arrancas un proyecto, decides un montón de cosas. Framework, base de datos, cómo vas a manejar autenticación, qué patrón de logging. Pero hay una decisión que casi nunca aparece en esa lista: para cada pedazo de código que vas a escribir, ¿tiene el mismo ciclo de vida que un request HTTP?
Si la respuesta es sí (la lógica nace cuando entra el request y muere cuando sale la respuesta), pertenece al API. Sin discusión.
Si la respuesta es no (el código vive más, espera mensajes, mantiene una conexión abierta, debe correr exactamente una vez sin importar cuántas instancias del API existan), entonces no es código de API. Es otra cosa.
El consumidor de Service Bus del ejemplo es exactamente “otra cosa”. Su trabajo no termina cuando termina un request HTTP. Su trabajo dura tanto como dure la cola, lo que en producción significa “para siempre”. Meterlo en el API es forzarlo a heredar propiedades que no le sirven y, peor, que le hacen daño.
Eso es lo que se descubre, casi siempre, después de un incidente.
Por qué IHostedService dentro del API hereda mal
Cuando registras un IHostedService dentro de una Web API, ese servicio queda atado a tres cosas que son del API, no del job.
La primera es que se escala con el tráfico HTTP. Tu API se escala según RPS, latencia, CPU, lo que sea. Lo que decida tu plataforma. Cuando hay tráfico, suben réplicas. Cuando no, bajan. Eso está bien para el API. Para el consumer, es un desastre: si la cola está vacía y el API está saturado, levantas más consumers que no tienen nada que hacer. Y si la cola está llena pero el tráfico HTTP bajó, escalaste hacia abajo justo cuando necesitabas más capacidad de proceso.
La segunda es el lifecycle atado al pipeline web. El host arranca el BackgroundService antes de que ApplicationStarted se dispare. Esto está documentado en un issue del runtime y es exactamente lo que parece: tu job puede empezar a procesar mensajes antes de que Kestrel acepte requests, o seguir procesando durante un shutdown que el load balancer ya marcó como unhealthy. La asimetría es sutil hasta que te muerde.
La tercera es la peor. Desde .NET 6, una excepción no manejada en ExecuteAsync por defecto detiene el host completo. Está en la documentación oficial del cambio: la propiedad HostOptions.BackgroundServiceExceptionBehavior arranca en StopHost. Lo que significa que un fallo en tu consumer de Service Bus tira el API entero. Antes de .NET 6 la excepción se ignoraba silenciosamente, lo cual también era horrible pero al menos no te tumbaba el API. El cambio fue intencional y correcto (la doc lo explica con buen criterio), pero te obliga a pensar dónde corre cada cosa.
// Default actual: StopHostservices.Configure<HostOptions>(opts =>{ opts.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;});Lo que pasa cuando juntas estas tres herencias en un consumer dentro del API es exactamente lo del Black Friday. La concurrencia que configuraste para “el job” se multiplica por las réplicas que el API levantó por “el tráfico”. Cuatro réplicas, cuatro consumers, cuatro emails. Si hubieras tenido ocho réplicas habrían sido ocho.
Worker Service en .NET, hecho bien
Un Worker Service es, técnicamente, un proceso aparte. Lo creas con dotnet new worker, tiene su propio IHost, no carga el pipeline HTTP, no comparte deploy con el API, escala según sus propias métricas (longitud de cola, lag de Service Bus, lo que aplique), y si se cae, se cae solo.
Para el caso del email, queda algo así. No te enfoques en el BackgroundService, eso es boilerplate. Lo que importa son las opciones del ServiceBusProcessor, porque ahí es donde se vuelve explícito todo lo que antes estaba oculto:
public sealed class OrderEmailWorker( ServiceBusClient client, IServiceScopeFactory scopeFactory) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken ct) { var processor = client.CreateProcessor( "order-confirmations", new ServiceBusProcessorOptions { MaxConcurrentCalls = 16, // default es 1 AutoCompleteMessages = false // default es true });
processor.ProcessMessageAsync += OnMessageAsync; processor.ProcessErrorAsync += OnErrorAsync;
await processor.StartProcessingAsync(ct); await Task.Delay(Timeout.Infinite, ct); }
private async Task OnMessageAsync(ProcessMessageEventArgs args) { await using var scope = scopeFactory.CreateAsyncScope(); var sender = scope.ServiceProvider .GetRequiredService<IEmailSender>();
var order = args.Message.Body.ToObjectFromJson<OrderPlaced>(); await sender.SendConfirmationAsync(order, args.CancellationToken);
await args.CompleteMessageAsync(args.Message); }
private Task OnErrorAsync(ProcessErrorEventArgs args) => Task.CompletedTask; // log y métricas omitidos por brevedad}Hay tres decisiones aquí que en el código viejo (dentro del API) estaban ocultas y ahora son explícitas.
MaxConcurrentCalls = 16 se decide una sola vez por proceso. No se multiplica por réplicas del API porque ya no hay réplicas del API metidas en esto. Si quieres más concurrencia, tocas este número o levantas más Workers. Y eso lo decides según la cola, no según el tráfico HTTP.
AutoCompleteMessages = false importa más de lo que parece. Si lo dejas en true (que es el default, y la documentación oficial lo aclara) y tu handler lanza una excepción, el mensaje se abandona automáticamente. Lo cual a veces es lo que quieres y a veces no. Tener el control explícito te obliga a pensar en idempotencia, dead letter queue, retries. Todas las cosas que diferencian un consumer de juguete de uno de producción.
El scope de DI se crea por mensaje, no por proceso. Un BackgroundService es singleton; tus servicios con EF Core o HttpClient tipados no deben serlo. Si te saltas esta línea, vas a tener bugs de los más frustrantes en .NET. Tracking de entidades fantasma, contextos compartidos, conexiones colgadas. Lo más sano es asumir desde el día uno que cada mensaje vive en su propio scope.
Los settings del Service Bus los saqué de la guía oficial de Azure sobre cómo mejorar performance, que recomienda registrar ServiceBusClient como singleton (eso lo hago en el Program.cs, no aquí) y ser explícito con las opciones del processor. La doc está bien escrita; vale la pena leerla cuando arrancas con esto.
Cuándo no es Worker Service
Antes de que termines este post pensando que todo job va en un Worker, te aviso. El error simétrico también existe. Mover todo a Worker Service es tan malo como meter todo en el API. La pregunta correcta sigue siendo por operación, no por sistema.
Worker Service es para carga continua, conexiones long-lived (Service Bus, RabbitMQ, Kafka, SignalR backplane), procesos que deben correr exactamente una vez sin importar cuántas instancias del API existan.
Azure Functions es para carga esporádica o impredecible. Pagas por ejecución, escala a cero, los triggers ya están soportados de forma nativa (Blob, Cosmos, Event Grid, Timer). Si tu job corre dos veces al día, no necesitas un contenedor 24/7. Necesitas Functions.
Hangfire o Quartz.NET dentro del API sigue siendo válido para jobs schedulados con UI de monitoreo, reintentos persistentes en BD, y casos donde quieres ver el dashboard de “qué falló anoche”.
IHostedService dentro del API está bien para una cosa: hooks de startup. Cache warmup, registro en service discovery, refresh de configuración inicial. Ahí el ciclo de vida sí coincide con el del API. No es un job en background. Es un hook que corre una vez por proceso al arrancar.
Si te das cuenta, todo el patrón es el mismo. Emparejar el ciclo de vida del código con el ciclo de vida del host que lo corre. Cuando emparejan, todo fluye. Cuando no, descubres el problema en producción.
Por qué sigue pasando
Esto que cuento no es un patrón nuevo ni un secreto. Worker Service existe como template oficial desde .NET 3.0. La documentación es clara. Hay charlas, hay posts, hay ejemplos por todos lados.
Pero sigue pasando. Porque en el momento de decidir, mover el código del consumer al API parece más simple. Es un solo deploy. Es la misma config. El mismo CI/CD. Total, “lo movemos después si hace falta”.
Y casi siempre, “después” es el día del incidente.
La conclusión que me llevo de haber visto este patrón varias veces es contraintuitiva. Pensar dónde va a vivir cada operación es trabajo de diseño, no de optimización. Y el diseño se hace al inicio, cuando duele poco. No después de que cuatro emails idénticos llegaron al mismo cliente.
Si estás arrancando un sistema ahora y tienes algo que se parece remotamente a un consumer, un job, un proceso de fondo, hazte la pregunta antes de escribir la primera línea. ¿Esto vive lo que vive un request HTTP?
Si la respuesta honesta es no, ya sabes dónde va.
¿Te tocó vivir un bug parecido? Cuéntame en LinkedIn. Me interesa saber cómo lo descubrieron y qué tan caro salió.