Core API Background Services

Some tasks in the BookStore API do not belong in an HTTP request-response cycle. Sending daily sales reports, clearing expired cache entries, checking for books with low stock, or generating daily summaries are tasks that should run on a schedule in the background — not triggered by a client request. ASP.NET Core provides Background Services (also called Hosted Services) for exactly this purpose.

What Is a Background Service?

A background service is a long-running process that starts when the application starts and stops when the application stops. It runs independently of HTTP requests — even if no client sends any request, the background service keeps running.

Application starts
     |
     ├── HTTP Pipeline → handles client requests
     │
     └── Background Services (run in parallel)
              ├── DailyReportService → sends email every midnight
              └── CacheCleanupService → removes stale cache every hour

IHostedService vs BackgroundService

InterfaceWhat It ProvidesUse When
IHostedServiceRaw StartAsync and StopAsync methodsFull control needed
BackgroundServiceAbstract class with ExecuteAsync to implementMost common — simpler to implement

BackgroundService is the easier option — implement just one method: ExecuteAsync.

Example 1 – Book Availability Check Service

This background service checks every hour for books that have been marked unavailable and logs a report.

// Services/BookAvailabilityCheckerService.cs
using BookStoreAPI.Data;
using Microsoft.EntityFrameworkCore;

namespace BookStoreAPI.Services
{
    public class BookAvailabilityCheckerService : BackgroundService
    {
        private readonly ILogger<BookAvailabilityCheckerService> _logger;
        private readonly IServiceProvider _serviceProvider;
        private readonly TimeSpan _interval = TimeSpan.FromHours(1);

        public BookAvailabilityCheckerService(
            ILogger<BookAvailabilityCheckerService> logger,
            IServiceProvider serviceProvider)
        {
            _logger = logger;
            _serviceProvider = serviceProvider;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _logger.LogInformation("Book Availability Checker started.");

            while (!stoppingToken.IsCancellationRequested)
            {
                await CheckUnavailableBooksAsync();

                // Wait 1 hour before running again
                await Task.Delay(_interval, stoppingToken);
            }

            _logger.LogInformation("Book Availability Checker stopped.");
        }

        private async Task CheckUnavailableBooksAsync()
        {
            _logger.LogInformation("[{Time}] Checking for unavailable books...", DateTime.UtcNow);

            // Background services must create their own scope to use Scoped services
            using var scope = _serviceProvider.CreateScope();
            var dbContext = scope.ServiceProvider.GetRequiredService<BookStoreDbContext>();

            var unavailableBooks = await dbContext.Books
                .Where(b => !b.IsAvailable)
                .ToListAsync();

            if (unavailableBooks.Count == 0)
            {
                _logger.LogInformation("All books are currently available.");
                return;
            }

            _logger.LogWarning("{Count} book(s) are currently unavailable:", unavailableBooks.Count);
            foreach (var book in unavailableBooks)
            {
                _logger.LogWarning("  - [{Id}] {Title} by {Author}", book.Id, book.Title, book.Author);
            }
        }
    }
}

Example 2 – Daily Summary Service

This service generates a daily count of books in the database at midnight every day.

// Services/DailySummaryService.cs
namespace BookStoreAPI.Services
{
    public class DailySummaryService : BackgroundService
    {
        private readonly ILogger<DailySummaryService> _logger;
        private readonly IServiceProvider _serviceProvider;

        public DailySummaryService(
            ILogger<DailySummaryService> logger,
            IServiceProvider serviceProvider)
        {
            _logger = logger;
            _serviceProvider = serviceProvider;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _logger.LogInformation("Daily Summary Service started.");

            while (!stoppingToken.IsCancellationRequested)
            {
                // Calculate time until next midnight
                var now = DateTime.UtcNow;
                var nextMidnight = now.Date.AddDays(1);
                var timeUntilMidnight = nextMidnight - now;

                _logger.LogInformation("Daily summary will run in {Hours}h {Minutes}m.",
                    (int)timeUntilMidnight.TotalHours, timeUntilMidnight.Minutes);

                // Wait until midnight
                await Task.Delay(timeUntilMidnight, stoppingToken);

                if (!stoppingToken.IsCancellationRequested)
                {
                    await GenerateDailySummaryAsync();
                }
            }
        }

        private async Task GenerateDailySummaryAsync()
        {
            using var scope = _serviceProvider.CreateScope();
            var dbContext = scope.ServiceProvider.GetRequiredService<BookStoreDbContext>();

            var totalBooks = await dbContext.Books.CountAsync();
            var availableBooks = await dbContext.Books.CountAsync(b => b.IsAvailable);
            var categories = await dbContext.Books
                .GroupBy(b => b.Category)
                .Select(g => new { Category = g.Key, Count = g.Count() })
                .ToListAsync();

            _logger.LogInformation("=== BookStore Daily Summary ({Date}) ===", DateTime.UtcNow.Date);
            _logger.LogInformation("Total books: {Total}", totalBooks);
            _logger.LogInformation("Available books: {Available}", availableBooks);
            _logger.LogInformation("Unavailable books: {Unavailable}", totalBooks - availableBooks);
            foreach (var cat in categories)
            {
                _logger.LogInformation("  Category '{Category}': {Count} books", cat.Category, cat.Count);
            }
        }
    }
}

Registering Background Services in Program.cs

// Program.cs
builder.Services.AddHostedService<BookAvailabilityCheckerService>();
builder.Services.AddHostedService<DailySummaryService>();

Why IServiceProvider Instead of Direct Injection?

Background services are registered as Singleton — they live for the entire application lifetime. However, BookStoreDbContext is registered as Scoped — it lives only for the duration of one HTTP request.

A Singleton cannot directly depend on a Scoped service — this causes a runtime error. The solution is to inject IServiceProvider and create a scope manually each time the background service needs to access the database.

Singleton (Background Service)
     |
     | ← Cannot inject Scoped services directly!
     |
IServiceProvider → CreateScope() → new scope → resolve Scoped DbContext
// Pattern: always use CreateScope() to access Scoped services in background services
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BookStoreDbContext>();
// use dbContext...
// scope disposes automatically at the end of the using block

Handling Cancellation Gracefully

The stoppingToken is signaled when the application is shutting down. Checking it prevents the service from starting a new task after shutdown begins:

while (!stoppingToken.IsCancellationRequested)
{
    // Do work...
    await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}

Passing stoppingToken to Task.Delay() means the delay is cancelled immediately when the app stops — no waiting for the full hour.

Background Service Lifecycle

App starts
  → BackgroundService.StartAsync() is called automatically
  → ExecuteAsync() begins running

App receives requests normally
  → Background service runs independently (on its own thread)

App stops (Ctrl+C or deployment restart)
  → CancellationToken is signaled
  → ExecuteAsync() exits the while loop
  → BackgroundService.StopAsync() cleans up

Key Points

  • Background services run independently of HTTP requests — they start with the app and stop when the app stops.
  • Inherit from BackgroundService and implement ExecuteAsync — this is the recommended approach.
  • Register background services with AddHostedService<T>() in Program.cs.
  • Background services are Singletons — use IServiceProvider.CreateScope() to access Scoped services like DbContext.
  • Always check stoppingToken.IsCancellationRequested in the loop and pass the token to Task.Delay() for graceful shutdown.

Leave a Comment