Background Jobs in Clean Architecture

Background Jobs in Clean Architecture

6 min read··

aspnetcorebackground-jobsclean-architecturedotnet

A background job in Clean Architecture is just another entry point, like a controller. The job class lives in the infrastructure or presentation ring and stays thin: it schedules and triggers, while the work itself is an application-layer use case.

Sooner or later, every codebase grows a background job with 300 lines of business logic inside ExecuteAsync. Order expiration, email digests, data cleanup, all living in a class the domain layer has never heard of. Here is the structure that prevents it, plus the scoped-service wiring that trips everyone up.

Which Layer Do Background Jobs Belong To?

Think about what a controller does in Clean Architecture: it accepts input from the outside world (HTTP), translates it into a use case invocation, and returns the result. It contains no business rules.

A background job is the same thing with a different trigger. Instead of an HTTP request, the trigger is a schedule, a timer, or a queue message. Everything after the trigger should be identical: invoke a use case in the application layer.

That gives you the placement rule:

  • The job class (the BackgroundService, the Quartz IJob, the Hangfire method) lives in infrastructure or presentation, wherever your other entry points live.
  • The work itself is an application-layer use case: a command handler or an application service.
  • The domain rules the work enforces stay in the domain layer.
A background job and an HTTP controller are two triggers for the same use case: both create a scope and send a command to a handler in the application layer, which invokes the domain rules

The payoff is the same one controllers get. The use case is testable without any scheduler, reusable from other entry points (an admin endpoint can trigger the same cleanup), and wrapped by the same cross-cutting behaviors: validation, logging, transactions.

If the logic is in the job class, none of that is true. You cannot invoke it from anywhere else, and testing it means fighting the scheduling machinery, which is exactly the pain I described in testing background services.

The Mechanical Detail: Scoped Services in a Singleton

Before the examples, the one wiring detail that trips up everyone.

A BackgroundService is registered as a singleton and lives for the entire process. Your application services, your DbContext, and your MediatR handlers are scoped. Inject a scoped service into the singleton constructor and you get the infamous "Cannot consume scoped service from singleton" error, or worse, a single DbContext instance shared across the whole application lifetime.

The fix is always the same: inject IServiceScopeFactory and create a scope per execution. I covered the underlying rules in using scoped services from singletons, and you will see the pattern in both examples below.

Option 1: BackgroundService Triggering a Use Case

Start with the use case itself, in the application layer. It knows nothing about scheduling:

public sealed record ExpireStaleOrdersCommand : IRequest<int>;

public sealed class ExpireStaleOrdersCommandHandler(
    IOrderRepository orderRepository,
    IUnitOfWork unitOfWork,
    TimeProvider timeProvider)
    : IRequestHandler<ExpireStaleOrdersCommand, int>
{
    public async Task<int> Handle(
        ExpireStaleOrdersCommand command,
        CancellationToken ct)
    {
        DateTime cutoff = timeProvider.GetUtcNow().UtcDateTime.AddHours(-24);

        IReadOnlyList<Order> staleOrders =
            await orderRepository.GetPendingOlderThanAsync(cutoff, ct);

        foreach (Order order in staleOrders)
        {
            order.Expire();
        }

        await unitOfWork.SaveChangesAsync(ct);

        return staleOrders.Count;
    }
}

The Expire method is a domain behavior on the Order entity. The handler orchestrates; the entity enforces the rules.

Now the job, in infrastructure. It is a scheduling shell around the command:

public sealed class ExpireStaleOrdersJob(
    IServiceScopeFactory scopeFactory,
    ILogger<ExpireStaleOrdersJob> logger) : BackgroundService
{
    private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(Interval);

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using IServiceScope scope = scopeFactory.CreateScope();

                ISender sender = scope.ServiceProvider
                    .GetRequiredService<ISender>();

                int expired = await sender.Send(
                    new ExpireStaleOrdersCommand(), stoppingToken);

                logger.LogInformation("Expired {Count} stale orders", expired);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Order expiration run failed");
            }
        }
    }
}

Register it in Program.cs:

builder.Services.AddHostedService<ExpireStaleOrdersJob>();

Three details worth noticing:

  • The scope is created per tick, so each run gets a fresh DbContext and properly scoped dependencies.
  • The try/catch swallows failures per run instead of letting one exception kill the loop forever. An exception that escapes ExecuteAsync stops the job for good, and since .NET 6 the default BackgroundServiceExceptionBehavior takes the entire host down with it.
  • Cancellation flows through, so shutdown is clean.

The shell never changes as business logic evolves. Every future change happens in the handler, where it is unit-testable.

Option 2: Quartz Job Triggering the Same Use Case

PeriodicTimer loops are fine for simple intervals. For cron schedules, persistence, and no-overlap guarantees, I reach for Quartz:

dotnet add package Quartz.Extensions.Hosting

The Quartz job is even thinner, because the Microsoft DI integration resolves every job from a fresh scope per execution:

[DisallowConcurrentExecution]
public sealed class ExpireStaleOrdersQuartzJob(ISender sender) : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        await sender.Send(
            new ExpireStaleOrdersCommand(),
            context.CancellationToken);
    }
}

And the wiring:

builder.Services.AddQuartz(options =>
{
    var jobKey = JobKey.Create(nameof(ExpireStaleOrdersQuartzJob));

    options.AddJob<ExpireStaleOrdersQuartzJob>(jobKey)
        .AddTrigger(trigger => trigger
            .ForJob(jobKey)
            .WithCronSchedule("0 0/15 * * * ?"));
});

builder.Services.AddQuartzHostedService(options =>
{
    options.WaitForJobsToComplete = true;
});

Because Quartz resolves the job from a scope, ISender injects directly. No IServiceScopeFactory ceremony. DisallowConcurrentExecution prevents overlapping runs when one execution outlasts its schedule, and in a clustered Quartz setup that guarantee holds across application instances, something the timer loop cannot offer.

The important part: the handler did not change. Swapping the scheduling technology (timer loop, Quartz, Hangfire, a queue consumer) touches only the outer shell. That is the dependency rule doing its job.

Where the Boundaries Earn Their Keep

This structure pays off in three specific ways.

Testing. The handler is a plain class with injected interfaces. Unit test it like any other use case, the way I showed in unit testing Clean Architecture use cases. Zero scheduler involvement, and TimeProvider makes the cutoff logic deterministic.

Reuse. A support engineer needs to force-run the cleanup? Add an admin endpoint that sends the same command. The job and the endpoint are two triggers for one use case.

Cross-cutting behaviors. If your pipeline has validation, logging, and transaction behaviors, jobs get them for free by going through ISender. Job-embedded logic bypasses all of it.

One boundary question comes up often: what about the outbox processor, the job that publishes pending integration events? That one is legitimately pure infrastructure. It moves messages, it invokes no business use case, so it can live entirely in the infrastructure layer without an application-layer command. The rule is not "every job sends a command"; it is "business logic never lives in the job". The outbox pattern processor has no business logic, so it is fine as-is.

I go deep on structuring entry points, use cases, and the dependency rule in Pragmatic Clean Architecture, including how jobs and messaging consumers fit the same shape.

Summary

Background jobs do not need a special place in Clean Architecture, because they are not special. They are entry points: a schedule instead of an HTTP request, a scoped execution instead of a request scope, and a use case invocation at the center either way.

Keep the job class thin enough that it never needs a test beyond "it sends the command". Create a scope per execution, catch per-run exceptions so the loop survives, and let Quartz handle cron and overlap when the schedule gets serious.

If your job class has business rules in it today, extract them into a command handler. The job will shrink to ten lines, and the logic will finally be testable.

Frequently Asked Questions

Which layer do background jobs belong to in Clean Architecture?

The infrastructure or presentation ring, alongside controllers. A job is an entry point that triggers a use case. The business logic it runs belongs in the application layer, not in the job class itself.

Why can a BackgroundService not inject scoped services directly?

BackgroundService instances are singletons that outlive every request scope. Injecting a scoped service like a DbContext into a singleton either fails at startup or captures one instance forever. The fix is to inject IServiceScopeFactory and create a scope per execution.

Should background jobs use MediatR commands?

It works well. If your use cases are commands with handlers, a job simply sends a command, which keeps the job thin and reuses validation and cross-cutting behaviors. A plain application service interface achieves the same goal without MediatR.

How do I test logic that runs in a background job?

If the job only triggers a use case, you test the use case directly like any application-layer handler. The scheduling shell needs at most a smoke test that verifies it resolves a scope and invokes the right command.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.