Configuring and Running .NET Core Console Application with Generic Host Builder Utilizing Dependency Injection

What is Host Builder?

Host Builder is a collection of building blocks, in which you tell your application which environment it will run in, which setting files it will use, which functionalities it can utilize, where and how to log and what to do on startup. Then you attach a lifetime to host builder object depending on what you want it to be; a console application, a windows service or a linux daemon. Build it to create a Host object and run this Host object.

Host builder concept was first introduced to .NET Core as WebHostBuilder for ASP .NET Core projects. Perhaps that's the reason why almost all Host builder configuration and dependency injection examples are for ASP .NET Core. With time, Web Host Builder evolved into Generic Host Builder, which is shipped under Microsoft.Extensions.Hosting package. ASP .NET Core projects for latest versions of .NET Core have also migrated to Generic Host from Web Host. It's possible to use this Generic Host Builder, from within .NET Core console application projects too.

First, we start by creating an instance of HostBuilder object and tell it in which environment it will run.


        public IHostBuilder SetupHost()
        {
            return new HostBuilder()
                //Set environment (dev/prod)
                .UseEnvironment(GetEnvironement())

where GetEnvironment() simply checks an environment variable. You can use other methods which suits your needs.


        private string GetEnvironement()
        {
            //environment variable is set to "development" for development environment
            var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
            if (string.IsNullOrWhiteSpace(environment))
                environment = "production";
            return environment;
        }

Using Configuration Files

With the knowledge of environment, we chain in application configuration; setting base path and telling which configuration files it will use. So for development environment, this piece of code will load 2 configuration files: "appsettings.json" and "appsettings.development.json" from application base directory. If code determines it's running in production environment, again two configuration files will be loaded: "appsettings.json" and "appsettings.production.json". This functionality is shipped with Microsoft.Extensions.Configuration package.


                //Setup configuration files
                .ConfigureAppConfiguration((hostingContext, config) =>
                {
                    var env = hostingContext.HostingEnvironment;

                    config.SetBasePath(Path.Combine(AppContext.BaseDirectory))
                        .AddJsonFile("appsettings.json", false, false)
                        .AddJsonFile($"appsettings.{env.EnvironmentName}.json", false, false);
                })

Then we start injecting configuration parameters from configuration files.


                    .ConfigureServices((hostingContext, services) =>
                    {
                        services.Configure<GeneralOptions>(hostingContext.Configuration.GetSection("GeneralOptions"));
                    })

Here, we read "GeneralOptions" block from configuration files, parse it into a class of type GeneralOptions, and inject this class to host builder as holder of these parameter values. Json section name and class type name don't need to match, however field names defined under them should. Consider json file content and class description below. If field names and types under GeneralOptions section in json configuration file match with property names and types under GeneralOptions class, they will be parsed automatically.


{
  "GeneralOptions": {
    "IntOptionName1": 90,
    "IntOptionName2": 100,
    "StringOptionName1": "value"
  }
}


    public class GeneralOptions
    {
        public int IntOptionName1 { get; set; }
        public int IntOptionName2 { get; set; }
        public string StringOptionName1 { get; set; }
    }

Creating and Injecting Services

It's all nice and good but we have not yet implemented any functionality that our application will be performing. Let's create an interface and a class implementing this interface to do something awesome.


    public interface IAwesomeService
    {
        Task<bool> DoSomethingAwesomeAsync();
    }

    public class AwesomeService : IAwesomeService
    {
        public async Task<bool> DoSomethingAwesomeAsync()
        {
            return true;
        }
    }

Now it's time to inject our new service with awesome functionality to host builder. So down the road somewhere, our application can get an instance of this service and use it.


                    .ConfigureServices((hostingContext, services) =>
                    {
                        services.AddSingleton<IAwesomeService, AwesomeService>();
                    })

Notice that we used a method called AddSingleton. This is one of the 3 ways to inject a service and it's very important to choose the correct one for your application to do as expected.

When you get an instance of a service injected by .AddSingleton you always get the same instance of the class.

If you used .AddTransient while injecting the service, you always get a new instance of the class whenever you get one.

.AddScoped returns the same object instance during the lifetime of a request, however will return a different instance for another request. This is more useful within ASP .NET context.

You can inject as many services and configuration options as you need.

Hooking up a Hosted Service

Finally it's time to tell our application what to do when it starts up. For this, we can inject a class implementing IHostedService interface or BackgroundService base class via AddHostedService method.

Please check documentation for possible types of services.


                    .ConfigureServices((hostingContext, services) =>
                    {
                        services.AddHostedService<AwesomeBackgroundService>();
                    })

To use our injected services and configuration from our AwesomeBackgroundService, simply get their instances from constructor like so:


    public class AwesomeBackgroundService : BackgroundService
    {
        private readonly IAwesomeService _aService;
        private readonly GeneralOptions _generalOptions;
        private readonly ILogger<AwesomeBackgroundService> _logger;

        public AwesomeBackgroundService(IAwesomeService aService, IOptions<GeneralOptions> generalOptions, ILogger<AwesomeBackgroundService> logger)
        {
            _aService = aService;
            _generalOptions = generalOptions.Value;
            _logger = logger;
        }

        //other code
    }

Notice that we didn't directly get a GeneralOptions class but with a wrapper of IOptions over GeneralOptions. Without getting into too much detail, there is also possibility of requesting IOptionsSnapshot wrapper over GeneralOptions class. The difference is IOptions reads underlying values once whereas IOptionsSnapshot reads them whenever requested. This is more meaningful whenever requested from a transient service's contructor. Also we have to set the reloadOnChange parameter of AddJsonFile method to true while defining configuration files so that we will be aware of changes to configuration file.

Setting up Logging

There is a variable of type ILogger in the AwesomeBackgroudService above. To make an ILogger instance available to any service constructor if they request it, we need to configure logging and tell our host builder how and where to log various messages like debug, trace, information and exceptions.


                .ConfigureLogging((hostingContext, logging) =>
                {
                    logging.ClearProviders();
                    logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"));
                    logging.AddConsole();
                    logging.AddApplicationInsights(hostingContext.Configuration.GetValue("ApplicationInsightsInstrumentationKey"));
                });

Above code tells the host builder, to clear any previously configured logging providers if any, get logging configuration from configuration files, add console logging and add azure application insights logging with instrumentation key value again being read from configuration.

from there on in, we can use ILogger instances within our code to log like so:


    public class AwesomeBackgroundService : BackgroundService
    {
        private readonly ILogger<AwesomeBackgroundService> _logger;

        //some code

        private async Task<bool> DoSomethingPrivatelyAsync(int id)
        {
            try
            {
                //some code
                _logger.LogInformation("DoSomethingPrivatelyAsync for {id} completed.", id);
                return true;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "DoSomethingPrivatelyAsync for {id} failed.", id);
                return false;
            }
        }
    }

Since we can, let's modify the AwesomeService class we have implemented before to use ILogger. With a few lines of code, we can start logging.


    public class AwesomeService : IAwesomeService
    {
        private readonly ILogger<AwesomeService> _logger;

        public AwesomeBackgroundService(ILogger<AwesomeService> logger)
        {
            _logger = logger;
        }

        public async Task<bool> DoSomethingAwesomeAsync()
        {
            _logger.LogInformation("Here we go!");
            return true;
        }
    }

This will automatically write logs to defined providers. Minimum log level can be configured in configuration file generally, by provider type, and by ILogger type.


  "Logging": {
    "LogLevel": {
      "Default": "Error",
      "MyNamespace.AwesomeBackgroundService": "Information",
      "MyNamespace.AwesomeService": "Information",
      "Microsoft.Hosting.Lifetime": "Information"
    },
    "ApplicationInsights": {
      "IncludeScopes": true,
      "LogLevel": {
        "Default": "None"
      }
    }
  }

Configuring Host Lifetime and Running Application

Now it is finally time to run the host builder we have created. For that we need to decide how we want it to run. Possible options may be a console application (or an application that will be deployed to docker container), a windows service if we are running on a Windows OS, a linux daemon if we are on a compatible OS. If you can't decide right now and want to keep your options open without the need to change code you can try out this snippet:


    class Program
    {
        static async Task Main(string[] args)
        {
            var isService = !(Debugger.IsAttached || args.Contains("--console"));
            var hostBuilder = SetupHost();

            if (isService)
            {
                using (var host = hostBuilder.UseSystemd().UseWindowsService().Build())
                {
                    await host.RunAsync();
                }
            }
            else
            {
                using (var host = hostBuilder.UseConsoleLifetime().Build())
                {
                    await host.RunAsync();
                }
            }
        }
    }

UseSystemd() and UseWindowsService() are defined in Microsoft.Extensions.Hosting.Systemd and Microsoft.Extensions.Hosting.WindowsServices packages respectively. These methods perform platform checks so they are mutually exclusive and can be safely chained. You can use sc.exe utility to register an executable as a windows service.

And finally, OutputType and RuntimeIdentifier parameter settings in project (.csproj) file enables to produce executable file for requested runtime. Below; OutputType is set to "Exe" and RuntimeIdentifier is defined as "win10-x64" so an executable file (.exe) for Windows 10 64-bit will be created during compile.


  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <RuntimeIdentifier>win10-x64</RuntimeIdentifier>
    <PublishTrimmed>true</PublishTrimmed>
  </PropertyGroup>