Console applications are back in vogue, hosting everything from input-focused command-line apps, worker services, and ASP.NET Core. As we all use current and future target frameworks, the upgraded console experience is the glow-up story of .NET.

This post will explore the relationship between the IHostingService and IHostApplicationLifetime interfaces and how we can utilize them to build a robust console experience.

Using IHostBuilder In Console Apps

In current versions of .NET 5+, we can write straightforward console applications or utilize the IHostBuilder interface. When choosing to use an IHostBuilder instance, we get access to infrastructural concepts that we would otherwise have to develop ourselves:

  • Dependency Injection
  • Logging
  • Configuration
  • Worker service and Host infrastructure
  • Application Lifetime Management

The use of the IHostBuilder interface is familiar to developers working with ASP.NET Core, but it applies to any console program.

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices((hostContext, services) => {
                services.AddHostedService<Worker>();
             });
}

The ConfigureServices build method allows us to register IHostedService implementations and the most common implementations inherit from the base class of BackgroundService, found under the Microsoft.Extensions.Hosting namespace.

When implementing a BackgroundService, we only need to override the ExecuteAsync method. The host builder infrastructure passes a CancellationToken, which we can use in all our async method calls.

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
        await Task.Delay(1000, stoppingToken);
    }
}

We can override other methods, like StartAsync, StopAsync, and Dispose, but t we should be very cautious in doing so. Mainly because the BackgroundService implementations perform important actions with the CancellationToken. Here is BackgroundService.StartAsync base method.

public virtual Task StartAsync(CancellationToken cancellationToken)
{
    // Create linked token to allow cancelling executing task from provided token
    _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    // Store the task we're executing
    _executingTask = ExecuteAsync(_stoppingCts.Token);

    // If the task is completed then return it, this will bubble cancellation and failure to the caller
    if (_executingTask.IsCompleted)
    {
        return _executingTask;
    }

    // Otherwise it's running
    return Task.CompletedTask;
}

Working With The Console Host’s Lifetime

We mentioned that two of the advantages of using the IHostBuilder abstraction are access to Dependency Injection and Application Lifetime Management. It’s good practice not to know the exact hosting context of our workers, but we still might need to know about certain events:

  • When does the host start?
  • When is the host spinning down and getting ready to stop?
  • When has the host completely stopped.

These events are crucial in gracefully disposing of dependencies, such as database connections, flushing the logs, or releasing handles on files. So how do we use these events?

The static class Host found under the Microsoft.Extensions.Hosting namespace registers the IHostApplicationLifetime abstraction into the Dependency Injection container. The interface allows us to register methods for the following events:

  • ApplicationStarted
  • ApplicationStopping
  • ApplicationStopped

Let’s see what the implementation looks like in our code base.

private readonly ILogger<Worker> _logger;
private readonly IHostApplicationLifetime _lifetime;

public Worker(ILogger<Worker> logger, IHostApplicationLifetime lifetime)
{
    _logger = logger;
    _lifetime = lifetime;
}

public override Task StartAsync(CancellationToken cancellationToken)
{
    _lifetime.ApplicationStarted.Register(OnStarted);
    _lifetime.ApplicationStopping.Register(OnStopping);
    _lifetime.ApplicationStopped.Register(OnStopped);

    return base.StartAsync(cancellationToken);
}

It is essential to call the base class’ StartAsync, or else the worker will never execute the ExecuteAsync method and its actions. We could also invoke the ExecuteAsync procedure ourselves, but the original implementation has some complexities, as we saw previously.

Ordering BackgroudWorkers Matters For Events

While writing this post, I found that workers and their order of execution depend on the registration order within the ConfigureServices method.

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureServices((hostContext, services) => {
            services.AddHostedService<Worker>();
            services.AddHostedService<SecondWorker>();
         });

Given that we have registered Worker and then SecondWorker, the infrastructure will first call the StartAsync methods in the order of registration, but the events in the reverse order or registration. The console output from the workers makes this apparent.

// identical in both workers
private void OnStarted()
{
    Program.Count++;
    _logger.LogInformation($"OnStarted has been called: {Program.Count}");

    // Perform post-startup activities here
}

We can see the resulting order of events by looking at the logger output.

info: WorkerService.Worker[0]
      Worker running at: 03/05/2021 11:58:41 -05:00
info: WorkerService.SecondWorker[0]
      Worker running at: 03/05/2021 11:58:41 -05:00
info: WorkerService.SecondWorker[0]
      OnStarted has been called: 1
info: WorkerService.Worker[0]
      OnStarted has been called: 2
info: WorkerService.SecondWorker[0]
      OnStopping has been called.
info: WorkerService.Worker[0]
      OnStopping has been called.
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...
info: WorkerService.SecondWorker[0]
      OnStopped has been called.
info: WorkerService.Worker[0]
      OnStopped has been called.

What’s interesting is the execution order of methods:

  1. Worker.StartAsync
  2. SecondWorker.StartAsync
  3. SecondWorker.OnStarted
  4. Worker.OnStarted
  5. SecondWorker.OnStopping
  6. Worker.OnStopping
  7. Application.Stopped
  8. SecondWorker.OnStopping
  9. Worker.OnStopping

The predictable pattern to calls can present an opportunity for sharing responsibilities between workers, but it’s likely best to treat workers as independently as possible.

Conclusion

The IHostBuilder abstraction can add much-needed infrastructure to a console application, helping us opt-in to concepts like dependency injection, logging, configuration, and more. Taking a dependency on IHostApplicationLifetime gives us the additional capability to start and stop our external dependencies gracefully. It is essential to recognize that the order of worker registration will determine the execution of event registrations.

I hope you found this post informational and consider using it in your next console application. If you have any thoughts or an experience you’d like to share, leave it in the comments.

References