AWS Developer Tools Blog

Working with dependency injection in .NET Standard: inject your AWS clients – part 2

In part 1 of this blog post, we explored using the lightweight dependency injection (DI) provided by Microsoft.Extensions.DependencyInjection. By itself, this is great for libraries and small programs, but if you’re building a nontrivial application, you have other problems to contend with:

  • You might have complex configuration needs (development versus production, multiple sources, etc.)
  • How do you implement logging cleanly?
  • Lifetime management is hard…

Fortunately, there is a solution – hosts! A host centralizes and simplifies the setup of these application-wide concerns. In the .NET Core 1.0 release, Web Host was introduced to simplify the setup of web applications. In the release of .NET Core 2.1, Generic Host was added to support developers of any tech stack.

These hosts are very similar, and the Generic Host might eventually replace the Web Host (as mentioned in the Microsoft documentation at the time of this post). If you’re using the Web Host, the main distinction from the Generic Host is use of the Startup class. If you’re building a web application, you should use the Web Host. Otherwise, use the Generic Host. The example in this post uses the Generic Host.

Demo

Let’s take a look at the example from part 1, rewritten to use the Generic Host. To recap, this application simply lists out all Amazon S3 buckets and Amazon DynamoDB tables in the account.

Demo.cs

using System.Threading.Tasks;
using Amazon.DynamoDBv2;
using Amazon.S3;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace WorkingWithDI
{
    public class Demo
    {
        public static async Task Main()
        {
            await WithGenericHost();
        }

        private static async Task WithGenericHost()
        {
            // New Generic Host that has configuration, DI, logging, 
            // and lifetime management in a centralized location.
            var host = new HostBuilder()
                .ConfigureAppConfiguration(builder =>
                    { builder.AddJsonFile("appsettings.json"); })
                .ConfigureServices((context, collection) =>
                {
                    // App configuration is automatically injected.

                    // Inject Loggers.
                    collection.AddLogging();

                    collection.AddAWSService<IAmazonS3>();
                    collection.AddAWSService<IAmazonDynamoDB>();

                    // Inject lifetime management. We can have multiple
                    // hosted services.
                    collection.AddHostedService<Application>();
                })
                // Libraries are available to simplify logging setup.
                .ConfigureLogging(builder => { builder.AddConsole(); })
                // We want our app to respond to Ctrl+C. Other lifetimes are available.
                .UseConsoleLifetime()
                .Build();

            // Create all hosted services, and call their StartAsync methods.
            await host.RunAsync();
        }
    }
}

Instead of manually creating a ServiceCollection, the host gives us an instance. This collection already contains relevant configuration, so we don’t have to explicitly add it. The collection also has some new extension methods: AddLogging to inject the loggers provided by the host, and AddHostedService to specify which objects to manage. Besides setting up DI with configuration and logging, the host also manages the application lifetime and communicates this information between its hosted services. In this case, we only have one hosted service: Application.

Application.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Amazon.DynamoDBv2;
using Amazon.S3;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace WorkingWithDI
{
    // Implement IHostedService for lifetime methods
    sealed class Application : IHostedService, IDisposable
    {
        private readonly IAmazonS3 _amazonS3;
        private readonly IAmazonDynamoDB _amazonDynamoDb;
        // Logger is injected.
        private readonly ILogger<Application> _logger;
        // ApplicationLifetime management object is injected.
        private readonly IApplicationLifetime _applicationLifetime;

        public Application(
            IAmazonS3 amazonS3,
            IAmazonDynamoDB amazonDynamoDb,
            // We set logger and applicationLifetime to default to null for
            // unit tests.
            ILogger<Application> logger = null,
            IApplicationLifetime applicationLifetime = null) =>
            (_amazonS3, _amazonDynamoDb, _logger, _applicationLifetime) =
            (amazonS3, amazonDynamoDb, logger, applicationLifetime);

        private async Task Run(CancellationToken cancellationToken)
        {
            var bucketNames = string.Join(Environment.NewLine, 
                await GetBucketNames(cancellationToken));
            Console.WriteLine(bucketNames);

            Console.WriteLine();

            var tableNames = string.Join(Environment.NewLine, 
                await GetTableNames(cancellationToken));
            Console.WriteLine(tableNames);
        }

        private async Task<IEnumerable<string>> GetBucketNames(
            CancellationToken cancellationToken) =>
            (await _amazonS3.ListBucketsAsync(cancellationToken)).Buckets
            Select(bucket => bucket.BucketName);

        private async Task<IEnumerable<string>> GetTableNames(
            CancellationToken cancellationToken) =>
            (await _amazonDynamoDb.ListTablesAsync(cancellationToken)).TableNames;

        // For host
        public async Task StartAsync(CancellationToken cancellationToken)
        {
            await Run(cancellationToken);
            _logger.LogInformation("Starting!");
            _applicationLifetime.StopApplication();
        }

        public Task StopAsync(CancellationToken cancellationToken)
        {
            _logger.LogInformation("Stopping!");
            return Task.CompletedTask;
        }

        public void Dispose()
        {
            _amazonS3?.Dispose();
            _amazonDynamoDb?.Dispose();
        }
    }
}

Application now has a logger and a lifetime management object injected into its constructor. It also implements IHostedService, and has StartAsync and StopAsync hooks to enable the host to relay lifetime-related events. These methods take cancellation tokens, so we should always funnel those down to our async calls to ensure we can correctly respond to lifetime directives from the host in a timely way. We no longer need to call Run directly, so we have made it private.

We could have extended BackgroundService instead of implementing IHostedService directly. This example uses IHostedService to be explicit about lifetime and to show logging examples. If you’re building a continuously or long-running service, consider using BackgroundService; implement ExecAsync, and only implement StartAsync and StopAsync when needed.

Let’s run through an execution:

  1. “dotnet run” is executed on the compiled application.
  2. Program.Main is executed, which calls Program.WithGenericHost.
  3. The host is defined, using settings from the “appsettings.json” file, a ServiceCollection for DI (with the services it’s responsible for hosting), a logger, and what runtime to use (act like a console application).
  4.  The host starts, and creates an Application hosted service, giving runtime dependencies, a logger instance, and a lifetime handle or delegate.
  5. Host calls StartAsync on the application.
  6.  Application does work.
  7. Application signals to the host to close the application through applicationLifetime.
  8.  Host calls StopAsync on the application.
  9.  Host has stopped all hosted services, and exits.

Because we’re using ConsoleLifetime, the application responds to other signals, like Ctrl+C. If we enter that keyboard shortcut, the host will call StopAsync on all HostedServices (Application).

Conclusion

And there it is! By changing a few blocks of code, we significantly increase the quality of our application! We now have cleaner, simpler DI, logging, and lifetime management.

If you’re using a Web Host, this is still relevant, but you are probably using controllers, which differ from hosted services. You should inject loggers into your controllers, but controller lifetime is managed for you.

Consider using a host in your next application!