165 built a BackgroundService that processed one queue. OrderFlow needs the same base class to run a whole checkout pipeline — and to be honest about exactly what it can't yet guarantee.
333 named Availability as one of OrderFlow's four real constraints: checkout has to stay fast even when a downstream dependency is slow. 336 built the database write that makes an Order durable the instant checkout returns. What's still missing is everything that has to happen after that write — charging the customer through PaymentService, reserving stock through InventoryService, scheduling a shipment through ShippingService, and emailing the customer through NotificationService — none of which the customer's browser should ever have to sit and wait for.
165 already taught you IHostedService and BackgroundService in depth, including a queue-and-worker shape nearly identical to what OrderFlow needs. This lesson doesn't re-teach that mechanism — it applies it to OrderFlow's real four-stage pipeline, and it's honest, up front, about the one real limitation this specific approach has, which is exactly what sets up 339.
OrderFlow's checkout does exactly two things synchronously, inside the HTTP request: validate the order (335's authorization, 334's domain rules) and save it (336). Everything after that — payment, inventory, shipping, notification — runs as background work, queued the instant the order is saved and picked up by a BackgroundService that never blocks the response that already went back to the customer.
333's Availability constraint was specific: checkout has to stay fast even when payment, inventory, or shipping is slow or down. If OrdersController.PlaceOrder called PaymentService synchronously, the customer's browser would sit on an open connection for however long the payment provider takes to respond — and if the provider is degraded, that could be many seconds, or a timeout, for every single checkout happening at that moment. 165 already established the fix in the abstract: a fast producer (the controller) enqueues work; a separate, long-running BackgroundService consumes it on its own schedule. OrderFlow just needs that shape stretched across four real, sequential steps instead of one.
public record OrderPlacedWorkItem(Guid OrderId);
public interface IBackgroundTaskQueue
{
ValueTask QueueAsync(OrderPlacedWorkItem item, CancellationToken ct);
IAsyncEnumerable<OrderPlacedWorkItem> DequeueAllAsync(CancellationToken ct);
}
public class InMemoryTaskQueue : IBackgroundTaskQueue
{
private readonly Channel<OrderPlacedWorkItem> channel = Channel.CreateUnbounded<OrderPlacedWorkItem>();
public ValueTask QueueAsync(OrderPlacedWorkItem item, CancellationToken ct) =>
channel.Writer.WriteAsync(item, ct);
public IAsyncEnumerable<OrderPlacedWorkItem> DequeueAllAsync(CancellationToken ct) =>
channel.Reader.ReadAllAsync(ct);
}
public class OrderProcessingService(
IBackgroundTaskQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessingService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var item in queue.DequeueAllAsync(stoppingToken))
{
try
{
using var scope = scopeFactory.CreateScope();
var pipeline = scope.ServiceProvider.GetRequiredService<OrderPipeline>();
await pipeline.RunAsync(item.OrderId, stoppingToken);
}
catch (Exception ex)
{
// Caught here, deliberately — an unhandled exception would stop
// this BackgroundService for the rest of the process's lifetime.
logger.LogError(ex, "Order pipeline failed for {OrderId}", item.OrderId);
}
}
}
}Meaning: IServiceScopeFactory creates a fresh DI scope per order — BackgroundService itself is a singleton (165), so it can never directly inject a scoped OrderFlowDbContext; a new scope per work item is what lets each order get its own tracked DbContext instance, exactly as a normal HTTP request would.
public class OrderPipeline(
IOrderRepository orders,
IPaymentGateway payment,
IInventoryReserver inventory,
IShippingScheduler shipping,
INotificationSender notifications)
{
public async Task RunAsync(Guid orderId, CancellationToken ct)
{
var order = await orders.GetByIdWithItemsAsync(orderId, ct)
?? throw new InvalidOperationException($"Order {orderId} not found.");
await payment.ChargeAsync(order, ct);
order.MarkPaymentConfirmed();
await inventory.ReserveAsync(order, ct);
order.MarkStockReserved();
await shipping.ScheduleAsync(order, ct);
order.MarkShipped();
await orders.UpdateAsync(order, ct);
await notifications.SendConfirmationAsync(order, ct);
}
}Every dependency here is an interface 334 placed in OrderFlow.Application — OrderPipeline has no idea whether payment is backed by Stripe or any other vendor, exactly the payoff 334 built the whole layered structure to guarantee.
A diner pays and gets a receipt the moment their order is placed — they don't stand at the counter until the food is actually cooked. The kitchen ticket goes to the back, where it's cooked, plated, and delivered on the kitchen's own timeline, entirely decoupled from how long the diner stood at the register. OrderFlow's checkout is the receipt — fast, immediate, done the instant the order exists. OrderProcessingService is the kitchen — working through payment, inventory, and shipping on its own schedule, never making the front counter wait on it.
165's Channel<T> lives entirely in the process's own memory. That's honest and important to say plainly for OrderFlow specifically: if the process crashes or restarts — a deploy, a container getting rescheduled, an unhandled exception nowhere near OrderProcessingService — every OrderPlacedWorkItem still sitting in the channel is gone. The Order row itself is safe (336's database write already committed before the item was even queued), but the pipeline that was supposed to charge, reserve, and ship it never resumes on its own. And because 333 established OrderFlow runs as more than one instance, this in-memory queue is also strictly per-instance — a work item queued on Instance A is invisible to Instances B and C, exactly the same shape of problem 337 already solved for caching, now showing up for background work instead.
This isn't a flaw to quietly work around inside this lesson — it's the precise, honest gap 339 exists to close. OrderProcessingService itself doesn't go away once 339 introduces Kafka and the Outbox pattern; it's still a BackgroundService, still built exactly the way 165 taught. What changes is only what it reads from — a durable, shared message stream instead of an in-memory channel that can't survive a restart or reach another instance.
BackgroundService is the durable answer to "how does OrderFlow run work outside the HTTP request" — that question doesn't change between this lesson and 339. What 339 changes is the durability and reach of what feeds that BackgroundService: an in-memory channel here, a Kafka topic with the Outbox pattern behind it there. Both lessons use exactly the same BackgroundService base class from 165 — 339 doesn't replace this lesson's mechanism, it replaces this lesson's queue.
The controller does await the call to QueueAsync — that's correct, not a mistake. What matters is that writing to an in-memory Channel<T> is itself extremely fast; the controller is never waiting on PaymentService, InventoryService, or anything slow. "Don't block the response" means don't block on the slow external work — it doesn't mean every await in the request path is forbidden.
Writing OrderProcessingService.ExecuteAsync without a try/catch around each work item, assuming "the pipeline just won't run for that one order." In reality, an unhandled exception inside a BackgroundService's ExecuteAsync stops that entire hosted service for the rest of the process's life — every order placed afterward silently stops being processed, with no visible error to the customer. Catch and log per work item, exactly as the Simple Example does, so one bad order never takes down the pipeline for every order after it.
Adding OrderFlowDbContext db as a constructor parameter on OrderProcessingService itself — BackgroundService is registered as a singleton, and a scoped service can't be safely resolved into a singleton's constructor. Use IServiceScopeFactory to create a new scope per work item, exactly as shown above — the same pattern 165 used for its own queue processor.
Shipping OrderFlow with only this lesson's Channel<T>-backed queue and considering the async pipeline "done" — a crash or restart genuinely loses queued work, and multiple instances genuinely can't share one instance's queue. Treat this lesson's version as the correct first step — real, working, and honestly explaining the mechanism — with 339 as the deliberate next step that makes it durable, not an optional nice-to-have.
You've seen OrderFlow's async checkout pipeline and the real limitation it still carries. Let's confirm the reasoning is solid before 339 closes that gap.
1. Why does OrderProcessingService use IServiceScopeFactory to create a new scope for each work item, instead of injecting OrderFlowDbContext directly into its constructor?
Correct: A
Why A is correct: This is exactly the lifetime mismatch Common Mistake 2 identifies — BackgroundService is a singleton, so it needs to create a fresh scope per unit of work to safely resolve any scoped dependency, DbContext included.
Why B is incorrect: DbContext can absolutely be used from a BackgroundService — just not injected directly into the singleton's own constructor; a scope makes it usable safely.
Why C is incorrect: await foreach has no relationship to DI scoping at all — this pairs two unrelated concepts.
Why D is incorrect: Scoping is about correct DI lifetimes, not performance — it has no direct bearing on how fast the payment provider responds.
Reinforcement: A singleton BackgroundService needs an explicit scope per work item to safely touch any scoped dependency.
2. A developer removes the try/catch inside OrderProcessingService.ExecuteAsync, reasoning that "if one order's pipeline throws, that one order just won't get processed." What does this lesson say actually happens?
Correct: B
Why B is correct: Common Mistake 1 states this precisely — an unhandled exception inside ExecuteAsync's loop stops that hosted service entirely, silently, for every order after the one that failed, not just the one that threw.
Why A is incorrect: This is exactly the false assumption the lesson warns against — the failure isn't scoped to one order, it silently disables all future processing.
Why C is incorrect: The web application itself (accepting HTTP requests) keeps running — it's specifically the background pipeline that silently stops, which is arguably worse since there's no obvious symptom.
Why D is incorrect: Nothing in the base BackgroundService/generic host automatically restarts a hosted service after it stops this way — that's precisely why catching per work item matters.
Reinforcement: Catch and log per work item inside a BackgroundService's loop — the alternative is a silent, total, ongoing failure of the whole service.
3. This lesson names a real limitation of its in-memory Channel<T>-backed queue. What is it, and what does the lesson say resolves it?
Correct: B
Why B is correct: Under the Hood states this directly — an in-memory queue can't survive a process restart and can't be shared across instances; 339 is explicitly named as the lesson that closes this gap with durable, shared messaging, while the BackgroundService mechanism itself stays unchanged.
Why A is incorrect: Raw speed was never the stated concern — an in-memory channel is actually very fast; durability and cross-instance visibility are the real gaps.
Why C is incorrect: The lesson doesn't identify single-item throughput as the limitation, and adding a second, uncoordinated BackgroundService instance wouldn't fix durability or sharing across processes anyway.
Why D is incorrect: The example code already uses async/await and await foreach throughout — this isn't a real limitation of the design shown.
Reinforcement: The gap is durability and cross-instance reach, not raw mechanism or throughput — and 339 fixes it by changing what feeds the queue, not by replacing BackgroundService itself.
Checkout is fast, and the pipeline behind it works. Next: 339 makes that pipeline durable and independently scalable — applying message-based architecture, Kafka, the Outbox pattern, and idempotency to OrderFlow's order-events flow.
dotnetmadeeasy.com — Learn C# and .NET, the right way.