In the last post we refactored the default Azure IoT Edge Module C# template code with a couple of dependency abstractions. In this post we will add a dependency injection mechanism in the mix and we will evolve this application example to a level that can be a good starting point for every Azure IoT Edge application.

In the final version of the Program.cs of the previous post, we reached a point where we could compose the dependencies together and create a business instance of the PipeModule. A simplified summarization of this composition is:

IConfiguration configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", optional: true)
    .AddEnvironmentVariables()
    .AddCommandLine(args)
    .Build();

var cancellationTokenSource = new CancellationTokenSource();

IModuleClient moduleClient = new ModuleClientWrapper();

IDatabaseClient databaseClient = 
	new DatabaseClientWrapper(configuration);

PipeModule pipeModule = new PipeModule(moduleClient, databaseClient);

In this dependency hierarchy, we had to explicitly pass the previously created instances to create more complex ones, all the way to creating the PipeModule. Although this approach is far better than the monolithic approach of the original template code, we still have hardcoded dependencies: to replace a dependency, we would still need to change this code, recompile and redeploy.

Wouldn't be nice if there was a mechanism that satisfied this hierarchy dependency composition dynamically, so that we can drive our changes via the configuration?

The Microsoft.Extensions.Hosting namespace contains many utility classes like the HostBuilder. This library is a good starting point because it references all other required libraries like the Microsoft.Extensions.Configuration, Microsoft.Extension.DependencyInjection and Microsoft.Extension.Logging and brings together all these abstractions and functionality under a few simple interfaces.

Note: to install this extension library, run:
dotnet add package Microsoft.Extensions.Hosting

In contrast to the Azure IoT SDK, this library excels in distinguishing the application from the host. The HostBuilder is a very useful utility that helps us to compose together our application services and our hosting infrastructure. A service is a component of our application, an element in our application dependency hierarchy, and the hosting infrastructure is anything specific to the hosting environment, like for example the environment variables configuration.

Let's now refactor our previous example by using the HostBuilder to configure our dependencies. We'll start first by implementing the IHostedService interface in our PipeModule class. This interface defines just two methods:

public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

And yes, the PipeModule from the last post already implements these two methods, so we should just add this interface in the class signature:

public class PipeModule : IHostedService

Now, we can replace the entire Program class with this code:

class Program
{
    static async Task Main(string[] args)
    {
        // Read the configuration
        IConfiguration configuration = new ConfigurationBuilder()
                .AddJsonFile("appsettings.json", optional: true)
                .AddEnvironmentVariables()
                .AddCommandLine(args)
                .Build();

        // Create the cancelation token source
        var cancellationTokenSource = new CancellationTokenSource();

        // Register to the application lifetime events
        AssemblyLoadContext.Default.Unloading +=
        (cts) => cancellationTokenSource.Cancel();

        Console.CancelKeyPress +=
            (sender, cts) =>
            {
                Console.WriteLine("Ctrl+C detected.");
                cts.Cancel = true;
                cancellationTokenSource.Cancel();
            };

        // Build the host
        using (var host = new HostBuilder()
            .ConfigureServices((hostContext, services) =>
            {
                services.AddSingleton(cancellationTokenSource);
                services.AddSingleton<IModuleClient, ModuleClientWrapper>();
                services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
                services.AddHostedService<PipeModule>();
            })
            .Build())
        {

            // Start the application
            host.Start();

            // Wait until the app unloads or is cancelled
            await WhenCancelled(cancellationTokenSource.Token);
        }

    }
    public static Task WhenCancelled(CancellationToken cancellationToken)
    {
        var taskCompletionSource = new TaskCompletionSource<bool>();
        cancellationToken.Register(
            s => ((TaskCompletionSource<bool>)s).SetResult(true),
            taskCompletionSource);
        return taskCompletionSource.Task;
    }
}
Note: the AddSingleton is used when we want to add a service that will be unique for the entire application. Singletons become the classes that carry state or identity, like the cancellationTokenSource and IModuleClient respectively.
Note: the AddHostedService is used for services that need to be started by the host.

We already saw how to control the application lifetime with the CancellationTokenSource in this post. The truth is that in the early versions of .NET Core, the AppDomain.CurrentDomain.ProcessExit event was replaced by the AssemblyLoadContext.Default.Unloading event, but now both of them exist and can be used interchangeably.

In the IHostBuilder we can find the UseConsoleLifetime() extension method, which simply adds an IHostLifetime service which is suitable for console apps, the Microsoft.Extensions.Hosting.Internal.ConsoleLifetime service. The IHostLifetime services control when the host starts and when it stops.

This ConsoleLifetime service:

Let's refactor the above code now using this lifecycle service:

class Program
{
    static async Task Main(string[] args)
    {
        // Read the configuration
        IConfiguration configuration = new ConfigurationBuilder()
                .AddJsonFile("appsettings.json", optional: true)
                .AddEnvironmentVariables()
                .AddCommandLine(args)
                .Build();

        // Build the host
        using (var host = new HostBuilder()
            .ConfigureServices((hostContext, services) =>
            {
                services.AddSingleton<IModuleClient, ModuleClientWrapper>();
                services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
                services.AddHostedService<PipeModule>();
            })
            .UseConsoleLifetime()
            .Build())
        {
            // Run the application and wait for the application to exit
            await host.RunAsync();
        }
    }
}
What do you know! After seven posts, we're back to a single-page long Main!

In the above version, instead of passing the CancellationTokenSource to our services to signal an application termination, we can inject the IHostApplicationLifetime service:

public DatabaseClientWrapper(IHostApplicationLifetime hostApplicationLifetime)

Then, we can register to the ApplicationStoppingevent to gracefully stop the service, and call the StopApplication() to trigger an application termination from inside the service.

A very good description of the startup and shutdown sequence can be found here  

We've also analyzed the difference between the application and the system configuration, and we examined a few best practices on managing this configuration. Similarly to the UseConsoleLifetime() extension, the ConfigureHostConfiguration and ConfigureAppConfiguration extension methods are there to help us read the application and the host configuration.

Let's see how this looks like:

class Program
{
    static async Task Main(string[] args)
    {
        // Build the host
        using (var host = new HostBuilder()
            .ConfigureHostConfiguration(configHost =>
            {
                configHost.SetBasePath(Directory.GetCurrentDirectory());
                configHost.AddJsonFile("hostsettings.json", optional: true);
                configHost.AddEnvironmentVariables(prefix: "PREFIX_");
                configHost.AddCommandLine(args);
            })
            .ConfigureAppConfiguration((hostContext, configApp) =>
            {
                configApp.AddJsonFile("appsettings.json", optional: true);
                configApp.AddJsonFile(
                    $"appsettings.{hostContext.HostingEnvironment.EnvironmentName}.json",
                    optional: true);
                configApp.AddEnvironmentVariables();
                configApp.AddCommandLine(args);
            })
            .ConfigureServices((hostContext, services) =>
            {
                services.AddSingleton<IModuleClient, ModuleClientWrapper>();
                services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
                services.AddHostedService<PipeModule>();
            })
            .UseConsoleLifetime()
            .Build())
        {
            // Run the application and wait for the application to exit
            await host.RunAsync();
        }
    }
}
Note that when we read the application configuration in the ConfigureAppConfiguration, we have access to the host configuration via the hostContext.Configuration property.

The application configuration now will automatically be injected to any service that depends on it, like the DatabaseClientWrapper we implemented in the last post.

A final step is to configure and hook up our logging logic. One alternative is to use the corresponding extension method, like for example:

.ConfigureLogging((logging) =>
{
    logging.AddFilter("Microsoft", LogLevel.Warning);
    logging.AddFilter("System", LogLevel.Warning);
    logging.AddFilter("DependencyInjection.Program", LogLevel.Debug);
    logging.AddConsole();
    logging.AddDebug();
    logging.AddEventSourceLogger();
    logging.AddEventLog();
})

If we do this, we've just replicated the behavior of the .NET Generic Host, which is just a pre-defined host configuration, as described here.

Then our code can be simplified to:

class Program
{
    static async Task Main(string[] args)
    {
        // Build default host
        using (var host = Host.CreateDefaultBuilder(args)
            .ConfigureServices((hostContext, services) =>
            {
                services.AddSingleton<IModuleClient, ModuleClientWrapper>();
                services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
                services.AddHostedService<PipeModule>();
            })
            .UseConsoleLifetime()
            .Build())
        {
            // Run the application and wait for the application to exit
            await host.RunAsync();
        }
    }
}

Alternatively, we can use a Serilog logger, driven by configuration. To do this, we will add the Serilog.Extensions.Hosting nuget package:

class Program
{
    static async Task Main(string[] args)
    {
        // Build default host
        using (var host = Host.CreateDefaultBuilder(args)
            .ConfigureServices((hostContext, services) =>
            {
                // Configure our services
                services.AddSingleton<IModuleClient, ModuleClientWrapper>();
                services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
                services.AddHostedService<PipeModule>();
            })
            .UseSerilog((hostingContext, log) =>
            {
                log.ReadFrom.Configuration(hostingContext.Configuration);
            })
            .UseConsoleLifetime()
            .Build())
        {
            // Run the application and wait for the application to exit
            await host.RunAsync();
        }
    }
}

By doing so, we get the expected logger injection behavior by adding a logger dependency in a constructor:

public DatabaseClientWrapper(ILogger<DatabaseClientWrapper> logger)

Also, the Serilog.Log.Logger is set implicitly, and can be accessed from everywhere by the static Serilog.Log property.

It is impressive how much plumbing code is hidden in this last version, but without losing the ability to override the default host builder if we want to, as we did with the Serilog logger.

Recap

In these mini-series blog posts we have explored various patterns and best practices and we managed to evolve the monolithic Azure IoT Edge module C# template code to a loosely coupled application that allows us have a true F5 development experience and define unit tests.

In the next post we will explore how we can use this loosely coupled design to setup our development environment.