blog post image
Andrew Lock avatar

Andrew Lock

~6 min read

Configuring environment specific services in ASP.NET Core - Part 2

In my previous post, I showed how you could configure different services for dependency injection depending on the current hosting environment, i.e. whether you are currently running in Development or Production.

The approach I demonstrated required storing the IHostingEnvironment variable passed to the constructor of your Startup class, for use in the ConfigureServices method.

In this post I show an alternative approach, in which you use environment-specific methods on your startup class, rather than if-else statements in ConfigureServices.

The default Startup class

By convention, a standard ASP.NET Core application uses a Startup class for application setting configuration, setting up dependency injection, and defining the middleware pipeline. If you use the default MVC template when creating your project, this will produce a Startup class with a signature similar to the following:

public class Startup
{
  public Startup(IHostingEnvironment env)
  {
    // Configuration settings
  }
  
  public void ConfigureServices(IServiceCollection services)
  {
    // Service dependency injection configuration
  }
  
  public void Configure(IApplicationBuilder app)
  {
    /// Middleware configuration
  }
}

Note that the Startup class does not implement any particular interface, or inherit from a base class. Instead, it is a simple class, which follows naming conventions for the configuration methods.

As a reminder, this class is referenced as part of the WebHostBuilder setup, which typically resides in Program.cs:

var host = new WebHostBuilder()
    .UseKestrel()
    .UseContentRoot(Directory.GetCurrentDirectory())
    .UseStartup<Startup>()
    .Build();

host.Run();

In addition to this standard format, there are some supplementary conventions that are particularly useful in the scenarios I described in my last post, where you want a different service injected depending on the runtime hosting environment. I'll come to the conventions shortly, but for now consider the following scenario.

I have an ISmsService my application uses to send SMS messages. In production I will need to use the full implementation, but when in development I don't want an SMS to be sent every time I test it, especially as it costs me money each time I use it. Instead, I need to use a dummy implementation of ISmsService.

Extension methods

In the previous post, I showed how you can easily use an if-else construct in your ConfigureServices method to meet these requirements. However, if you have a lot of these dummy services that need to be wired up, the method could quickly become long and confusing, especially in large apps with a lot of dependencies.

This can be mitigated to an extent if you use the extension method approach for configuring your internal services, similar to that suggested by K. Scott Allen in his post. In his post, he suggests wrapping each segment of configuration in an extension method, to keep the Startup.ConfigureServices method simple and declarative.

Considering the SMS example above, we might construct an extension method that takes in the hosting environment, and configures all the ancillary services. For example:

public static class SmsServiceExtensions
{
  public static IServiceCollection AddSmsService(this IServiceCollection services, IHostingEnvironment env, IConfigurationRoot config)
  {
    services.Configure<SmsSettings>(config.GetSection("SmsSettings"));
	services.AddSingleton<ISmsTemplateFactory, SmsTemplateFactory>();
    if(env.IsDevelopment())
	{
	  services.AddTransient<ISmsService, DummySmsService>();
	}
	else
	{
	  services.AddTransient<ISmsService, SmsService>();
	}
    return services;
  }
}

These extension methods encapsulate a discrete unit of configuration, all of which would otherwise have resided in the Startup.ConfigureServices method, leaving your ConfigureServices method far easier to read:

public void ConfigureServices(IServiceCollection services)
{
  services.AddMVC();
  services.AddSmsService(Environment, Configuration);
}

The downside to this approach is that your service configuration is now spread across many different classes and methods. Some people will prefer to have all the configuration code in the Startup.cs file, but still want to avoid the many if-else constructs for configuring Development vs Production dependencies.

Luckily, there is another approach at your disposal, by way of environment-specific Configure methods.

Environment-Specific method conventions

The ASP.NET Core WebHostBuilder has a number of conventions it follows when locating the configuration methods on the Startup class.

As we've seen, the Configure and ConfigureServices methods are used by default. The main advantage of not requiring an explicit interface implementation is that the WebHostBuilder can inject additional dependencies into these methods. However it also enables the selection of different methods depending on context.

As described in the documentation, the Startup class can contain environment specific configuration methods of the form Configure{EnvironmentName}() and Configure{EnvironmentName}Services().

If the WebHostBuilder detects methods of this form, they will be called preferentially to the standard Configure and ConfigureServices methods. We can use this to avoid the proliferation of if-else in our startup class. For example, considering the SMS configuration previously:

public class Startup
{
  public void ConfigureServices(IServiceCollection services)
    {
        ConfigureCommonServices(services);
        services.AddTransient<ISmsService, SmsService>();
    }

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureDevelopmentServices(IServiceCollection services)
    {
        ConfigureCommonServices(services);
        services.AddTransient<ISmsService, DummySmsService>();
    }

    private void ConfigureCommonServices(IServiceCollection services)
    {
        services.Configure<SmsSettings>(config.GetSection("SmsSettings"));
        services.AddSingleton<ISmsTemplateFactory, SmsTemplateFactory>();
    }
}

With this approach, we can just configure our alternative implementation services in the appropriate methods. At runtime the WebHostBuilder will check for the presence of a Configure{EnvironmentName}Services method.

When running in Development, ConfigureDevelopmentServices will be selected and the DummySmsService will be used. In any other environment, the default ConfigureServices will be called.

Note that we add all the service configuration that is common between environments to a private method ConfigureCommonServices, which is called by both configure methods. This prevents fragile duplication of configuration for services common between environments.

Environment-Specific class conventions

As well as the convention based methods in Startup, you can also take a convention-based approach for the whole Startup class. This allows you to completely separate your configuration code when in Development from other environments, by creating classes of the form Startup{Environment}.

For example, you can create a StartupDevelopment class and a Startup class - when you run in the Development environment, StartupDevelopment will be used for configuring your app and services. In other environments, Startup will be used.

So for example, we could have the following Startup Class

public class Startup
{
  public Startup(IHostingEnvironment env)
  {
    // Configuration settings
  }
  
  public void ConfigureServices(IServiceCollection services)
  {
    // Service dependency injection configuration
    services.AddTransient<ISmsService, SmsService>();
  }
  
  public void Configure(IApplicationBuilder app)
  {
    /// Middleware configuration
  }
}

and an environment-specific version for the Development environment:

public class StartupDevelopment
{
  public StartupDevelopment(IHostingEnvironment env)
  {
    // Configuration settings
  }
  
  public void ConfigureServices(IServiceCollection services)
  {
    // Service dependency injection configuration
    services.AddTransient<ISmsService, DummySmsService>();
  }
  
  public void Configure(IApplicationBuilder app)
  {
    /// Middleware configuration
  }
}

In order to use this convention based approached to your startup class, you need to use a different overload in the WebHostBuilder:

var assemblyName = typeof(Startup).GetTypeInfo().Assembly.FullName;

var host = new WebHostBuilder()
    .UseKestrel()
    .UseContentRoot(Directory.GetCurrentDirectory())
    .UseStartup(assemblyName)
    .Build();

host.Run();

Rather than using the generic UseStartup<T> method, we need to use the overload UseStartup(string startupAssemblyName). Under the hood, the WebHostBuilder will use reflection to find a Startup class in the provided assembly called Startup or Startup{Environment}. The environment-specific class will be used by default, falling back to the Startup class if no environment-specific version is found. If no candidate classes are found, the builder will throw an InvalidOperationException when starting your application.

Bear in mind that if you use this approach, you will need to duplicate any configuration that is common between environments, including application settings, service configuration and the middleware pipeline. If your application runs significantly differently between Development and Production then this approach may work best for you.

In my experience, the majority of the application configuration is common between all environments, with the exception of a handful of environment-specific services and middleware. Generally I prefer the if-else approach with encapsulation via extension methods as the application grows, but it is generally down to personal preference, and what works best for you.

Summary

In a previous post, I showed how you could use IHostingEnvironment to control which services are registered with the DI container at runtime, depending on the hosting environment.

In this post, I showed how you could achieve a similar result using naming conventions baked in to the WebHostBuilder implementation.

These conventions allow automatic selection of Configure{EnvironmentName} and Configure{Environment}Services methods in your Startup class depending on the current hosting environment.

Additionally, I showed the convention based approach to Startup class selection, whereby your application will automatically select a Startup class of the form Startup{Environment} if available.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?