← Open in the full interactive course (progress tracking, search & more)

Not every piece of work belongs inside an HTTP request. This project builds the thing that keeps running after the response has already gone out.

Every project so far in this part has done its work inside a request — a client asks for something, the server does the work, the server replies. That model breaks down the moment "the work" takes longer than a client should reasonably wait for: sending a batch of emails, generating a report, resizing an uploaded image, charging a payment and updating inventory. Making the caller sit there for ten seconds while all of that happens synchronously is a bad experience, and it ties up a request thread the whole time for no good reason.

The fix is to accept the request instantly, hand the actual work to something that keeps running independently of any single request, and let that something process a queue of work at its own pace. That "something" is a background service — a piece of code the .NET generic host starts when the application starts, runs for the application's entire lifetime, and stops cleanly when the application shuts down. This project builds exactly that: an order-processing pipeline where an API endpoint enqueues orders instantly, and a background worker drains that queue asynchronously, with proper cancellation and logging the whole way through.

Project Brief

Build a small order-processing pipeline: an API accepts orders instantly, and a background worker processes them asynchronously, off the request thread.

Requirements

Designing the System

Meet IHostedService and BackgroundService

Every ASP.NET Core app — and every plain console app started with Host.CreateApplicationBuilder — runs on top of the generic host, the same infrastructure that owns your DI container, configuration, and logging. The host also knows how to run hosted services: classes that start when the host starts and are given a chance to shut down cleanly when the host stops. That interface is IHostedService, with just two methods, StartAsync and StopAsync.

Writing IHostedService directly is unpleasant for the common case of "loop forever doing work," because you have to manually manage a background Task and wire up cancellation yourself. BackgroundService — an abstract base class in Microsoft.Extensions.Hosting — does that plumbing for you. You implement one method, ExecuteAsync(CancellationToken stoppingToken), write whatever loop you want inside it, and the base class takes care of starting it as a background task and requesting cancellation through stoppingToken when the host begins shutting down.

IHostedService — the raw interface

BackgroundService — the convenience base class

The queue: producer and consumer, connected by Channel<T>

The API endpoint (the producer) and the background worker (the consumer) run independently — the producer might add three orders while the worker is still finishing the first one. They need a shared, thread-safe hand-off point. You could reach for a plain Queue<T> with a lock, but .NET already has a purpose-built type for exactly this: System.Threading.Channels.Channel<T>, an async-native producer/consumer queue. A producer calls writer.WriteAsync(item); a consumer calls reader.ReadAsync() or, more conveniently, iterates reader.ReadAllAsync() with await foreach — and the consumer simply waits asynchronously whenever the queue is empty, instead of spinning or blocking a thread.

HOW AN ORDER FLOWS FROM REQUEST TO PROCESSED
1. CLIENT POSTS AN ORDER
2. THE ENDPOINT ENQUEUES AND RETURNS INSTANTLY
3. THE BACKGROUND SERVICE IS ALREADY LOOPING
4. THE ORDER IS DEQUEUED AND PROCESSED
5. RESULT IS LOGGED, LOOP CONTINUES

Building It Step by Step

Step 1 — Project setup

dotnet new web -n OrderProcessing
cd OrderProcessing

No extra packages needed — BackgroundService lives in Microsoft.Extensions.Hosting, and Channel<T> lives in System.Threading.Channels, both already part of the shared framework that an ASP.NET Core web project references by default.

Step 2 — The domain and the queue abstraction

public record Order(Guid Id, string CustomerEmail, decimal Total, DateTime PlacedAtUtc);

public interface IBackgroundTaskQueue
{
    ValueTask QueueOrderAsync(Order order, CancellationToken ct = default);
    IAsyncEnumerable<Order> DequeueAllAsync(CancellationToken ct);
}

Coding against an interface here — rather than a concrete Channel<Order> everywhere — is the same habit from the Notification Service project: it keeps the API endpoint and the background service each depending on a queue, not on channels specifically, so the actual queuing mechanism could later be swapped for a real message broker without touching either side's code.

Step 3 — Implementing the queue with Channel<T>

using System.Threading.Channels;

public class OrderTaskQueue : IBackgroundTaskQueue
{
    private readonly Channel<Order> _channel;

    public OrderTaskQueue(int capacity = 100)
    {
        var options = new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.Wait
        };
        _channel = Channel.CreateBounded<Order>(options);
    }

    public async ValueTask QueueOrderAsync(Order order, CancellationToken ct = default) =>
        await _channel.Writer.WriteAsync(order, ct);

    public IAsyncEnumerable<Order> DequeueAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

A bounded channel (as opposed to an unbounded one) has a fixed capacity — here, 100 pending orders. FullMode = BoundedChannelFullMode.Wait means that if the queue is already full, a producer's WriteAsync call asynchronously waits for room instead of throwing or silently dropping the order — a deliberate form of backpressure, so a burst of traffic can't grow the queue without limit and exhaust memory. ReadAllAsync(ct) returns an IAsyncEnumerable<Order> that yields items as they arrive and completes only when the channel itself is completed or the token is cancelled — exactly the shape await foreach from the async programming part was built for.

Step 4 — The background service itself

public class OrderProcessingService(
    IBackgroundTaskQueue taskQueue,
    ILogger<OrderProcessingService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Order processing service starting.");

        try
        {
            await foreach (var order in taskQueue.DequeueAllAsync(stoppingToken))
            {
                try
                {
                    await ProcessOrderAsync(order, stoppingToken);
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    // One bad order must never take down the worker loop
                    logger.LogError(ex, "Failed to process order {OrderId}.", order.Id);
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Expected during a graceful shutdown — not an error
        }

        logger.LogInformation("Order processing service stopping.");
    }

    private async Task ProcessOrderAsync(Order order, CancellationToken ct)
    {
        logger.LogInformation("Processing order {OrderId} for {Customer} (${Total}).",
            order.Id, order.CustomerEmail, order.Total);

        // Simulated real work: charge payment, update inventory, send confirmation...
        await Task.Delay(TimeSpan.FromSeconds(2), ct);

        logger.LogInformation("Order {OrderId} processed successfully.", order.Id);
    }
}

Two try/catch blocks are doing two different jobs, and it matters that they're nested the way they are. The inner one catches any exception from processing a single order — a payment failure, a bad address, whatever — logs it, and lets the await foreach move straight on to the next order, satisfying the "one bad order can't crash the worker" requirement. The outer one catches OperationCanceledException, which is exactly what DequeueAllAsync throws once stoppingToken is cancelled during shutdown — that's not a bug, it's the normal, expected way this loop ends, so it's caught quietly rather than logged as an error.

Step 5 — Registering everything

builder.Services.AddSingleton<IBackgroundTaskQueue>(_ => new OrderTaskQueue(capacity: 100));
builder.Services.AddHostedService<OrderProcessingService>();

The queue is registered singleton — deliberately, and unlike the DbContext from the EF Core project, which was scoped. There must be exactly one shared channel instance for the lifetime of the app; every request that posts an order and the one long-lived background worker all need to see the same queue, not a fresh one each time. AddHostedService<T> is what tells the generic host "start this BackgroundService when the app starts, and stop it when the app stops" — you never call ExecuteAsync yourself.

Step 6 — The producer endpoint

app.MapPost("/orders", async (
    CreateOrderRequest request,
    IBackgroundTaskQueue queue,
    ILogger<Program> logger,
    CancellationToken ct) =>
{
    var order = new Order(Guid.NewGuid(), request.CustomerEmail, request.Total, DateTime.UtcNow);

    await queue.QueueOrderAsync(order, ct);
    logger.LogInformation("Order {OrderId} enqueued.", order.Id);

    return Results.Accepted($"/orders/{order.Id}", order);
});

record CreateOrderRequest(string CustomerEmail, decimal Total);

Notice the status code: Results.Accepted (HTTP 202), not Results.Ok or Results.Created. 202 Accepted is the honest HTTP status for exactly this situation — "I've received your request and queued it, but I haven't actually finished the work yet, and I'm not making any promise about when I will." Returning 200 OK here would be a lie: nothing about this order has actually been processed by the time the response goes out. The CancellationToken injected into the endpoint comes from the current HTTP request — if the client disconnects before QueueOrderAsync completes, that write is cancelled too, which is the same request-scoped cancellation pattern from the async programming part.

Graceful shutdown, end to end: When the host begins shutting down (Ctrl+C, a container orchestrator sending SIGTERM, a deployment rolling forward), it signals stoppingToken. That cancels the pending ReadAllAsync inside DequeueAllAsync, which throws OperationCanceledException, which the outer catch in ExecuteAsync quietly absorbs — the loop ends instead of being killed mid-iteration. The host then waits for ExecuteAsync to actually return before considering the service stopped, up to a configurable timeout (HostOptions.ShutdownTimeout, five seconds by default) — long enough for an in-flight order to finish, but not so long that shutdown hangs forever on a stuck worker.

Complete Solution

using System.Threading.Channels;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IBackgroundTaskQueue>(_ => new OrderTaskQueue(capacity: 100));
builder.Services.AddHostedService<OrderProcessingService>();

var app = builder.Build();

app.MapPost("/orders", async (
    CreateOrderRequest request,
    IBackgroundTaskQueue queue,
    ILogger<Program> logger,
    CancellationToken ct) =>
{
    var order = new Order(Guid.NewGuid(), request.CustomerEmail, request.Total, DateTime.UtcNow);
    await queue.QueueOrderAsync(order, ct);
    logger.LogInformation("Order {OrderId} enqueued.", order.Id);
    return Results.Accepted($"/orders/{order.Id}", order);
});

app.Run();

// ── Domain and queue contract ──
record CreateOrderRequest(string CustomerEmail, decimal Total);
public record Order(Guid Id, string CustomerEmail, decimal Total, DateTime PlacedAtUtc);

public interface IBackgroundTaskQueue
{
    ValueTask QueueOrderAsync(Order order, CancellationToken ct = default);
    IAsyncEnumerable<Order> DequeueAllAsync(CancellationToken ct);
}

// ── Channel-backed queue implementation ──
public class OrderTaskQueue : IBackgroundTaskQueue
{
    private readonly Channel<Order> _channel;

    public OrderTaskQueue(int capacity = 100)
    {
        var options = new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.Wait
        };
        _channel = Channel.CreateBounded<Order>(options);
    }

    public async ValueTask QueueOrderAsync(Order order, CancellationToken ct = default) =>
        await _channel.Writer.WriteAsync(order, ct);

    public IAsyncEnumerable<Order> DequeueAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

// ── The background worker ──
public class OrderProcessingService(
    IBackgroundTaskQueue taskQueue,
    ILogger<OrderProcessingService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Order processing service starting.");

        try
        {
            await foreach (var order in taskQueue.DequeueAllAsync(stoppingToken))
            {
                try
                {
                    await ProcessOrderAsync(order, stoppingToken);
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    logger.LogError(ex, "Failed to process order {OrderId}.", order.Id);
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Expected during a graceful shutdown
        }

        logger.LogInformation("Order processing service stopping.");
    }

    private async Task ProcessOrderAsync(Order order, CancellationToken ct)
    {
        logger.LogInformation("Processing order {OrderId} for {Customer} (${Total}).",
            order.Id, order.CustomerEmail, order.Total);
        await Task.Delay(TimeSpan.FromSeconds(2), ct);
        logger.LogInformation("Order {OrderId} processed successfully.", order.Id);
    }
}

Run it, then POST a few orders in quick succession to /orders. Every request returns instantly with 202 Accepted, while the console log shows the background service picking each one up and finishing it roughly two seconds later, one after another — the request/response cycle and the actual processing are now two genuinely independent timelines.

Try It Yourself — Extension Challenges

Challenge 1 — A status endpointEasy

Add GET /orders/queue-status that reports how many orders are currently waiting to be processed.

Hint

Channel<T> doesn't expose a count directly, but Channel.CreateBounded<T> channels can be inspected through _channel.Reader.Count (available on the bounded channel implementation). Expose it from IBackgroundTaskQueue as an int PendingCount property, and return it from the new endpoint.

Challenge 2 — Retry a failed order onceMedium

If ProcessOrderAsync throws, retry that same order exactly once (after a short delay) before giving up and logging the failure.

Hint

Wrap the call to ProcessOrderAsync in the inner catch block with a small loop that attempts it at most twice: try, catch, await Task.Delay(...), try again, and only log an error if the second attempt also fails. This is the same simplified retry pattern from the Notification Service project's extension challenges — applied here to background work instead of a delivery channel.

Challenge 3 — Process orders with bounded concurrencyMedium

Right now, orders process strictly one at a time. Change OrderProcessingService so up to three orders can be processed concurrently, but never more than three at once.

Hint

Introduce a SemaphoreSlim(3, 3). Inside the await foreach loop, await semaphore.WaitAsync(stoppingToken) before starting a fire-and-forget Task.Run (or a locally tracked task) that calls ProcessOrderAsync and calls semaphore.Release() in a finally block when done. This is the same bounded-parallelism idea a SemaphoreSlim provides anywhere else in async C# — cap how many operations run at once without blocking the ones waiting their turn.

Challenge 4 — Persist the queue across restartsHard

Right now, any orders still sitting in the channel are lost if the process crashes or restarts before they're processed. Sketch (or implement) a design that survives that.

Hint

The in-memory Channel<T> is fundamentally volatile — nothing in-process can survive the process disappearing. A durable version writes each order to a real store first (a database table, or a proper message queue like a cloud queue service) as part of the POST /orders handler, and the background service's job becomes polling or subscribing to that store instead of an in-memory channel. This is the real-world reason production background processing usually sits on top of a message broker rather than an in-memory queue — it's the same producer/consumer shape, just backed by something that survives a restart.

Challenge 5 — A second, scheduled background serviceHard

Add a second BackgroundService, DailySummaryService, that runs entirely independently of the order queue and logs a summary line once every 24 hours (for testing, use a much shorter interval).

Hint

A periodic background service doesn't need a queue at all — its ExecuteAsync can simply be a loop: do the work, then await Task.Delay(interval, stoppingToken), then repeat, until stoppingToken is cancelled. Register it with a second builder.Services.AddHostedService<DailySummaryService>() call — the host happily runs any number of independent hosted services side by side, each with its own lifetime managed the same way.

Key Takeaway

You've built a real background worker using the generic host — the same pattern behind email queues, report generators, and job processors in production .NET systems everywhere. Next: one final challenge that pulls together everything from this entire tier.


dotnetmadeeasy.com — Learn C# and .NET, the right way.