Unclutter Startup.cs

Configuring service container and setting up request pipeline in ASP.NET Core can eat up a lot of lines of code, especially for more complex projects. A well-established way of doing this is using Startup.cs and its ConfigureServices() and Configure() methods. Although complete application setup can be packed into a single type, we must not forget about Single-responsibility principle. I wanted to show you a way to prevent startup from growing uncontrollably, by keeping different concerns separate from each other.

One disclaimer though. ASP.NET Core 6 has rolled in and removed the need of having Startup.cs altogether. Despite that fact, I am sure the ideas presented in this post will be relevant, even in the new era.

This post is part of C# Advent Calendar 2021. Cheers to Matthew D. Groves for letting me participate!

Basic principles

We will apply two .NET features: Options pattern and Extension methods. Similar to how ASP.NET Core uses plugin architecture, we will utilize these features to separate concerns and leave heavy lifting to dependency injection container.

One design standard, to keep in mind, is that almost all parts of ASP.NET Core are configured via IOptions pattern. And, since all IOptions<TOptions> instances are part of service container, we can use IConfigureOptions or IPostConfigureOptions to override any one of them.

MVC

Whether you are using Controllers only, or including Views or Razor Pages, MVC setup is more or less the same. The goal is to call AddControllers(), AddControllersWithViews() or AddRazorPages(), based on your scenario, and define configuration for MvcOptions, and possibly RazorPagesOptions. If you are using System.Text.Json or Newtonsoft Json.NET for model serialization, you could also configure them in a similar manner. Whole setup would look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static class MvcExtensions
{
public static IServiceCollection ConfigureMvc(this IServiceCollection services)
{
services.AddControllers().AddNewtonsoftJson();
services.AddSingleton<IConfigureOptions<MvcOptions>, ConfigureMvcOptions>();
services.AddSingleton<IConfigureOptions<MvcNewtonsoftJsonOptions>, ConfigureNewtonsoftOptions>();
return services;
}
}

public class ConfigureMvcOptions : IConfigureOptions<MvcOptions>
{
public void Configure(MvcOptions options)
{
options.SuppressAsyncSuffixInActionNames = true;
}
}

public class ConfigureNewtonsoftOptions : IConfigureOptions<MvcNewtonsoftJsonOptions>
{
public void Configure(MvcNewtonsoftJsonOptions options)
{
options.SerializerSettings.Converters.Add(new StringEnumConverter());
}
}

Authentication

Completely driving authentication by configuration is difficult. You probably need to know what type of authentication your application uses in advance. Good news is that, once baseline is established, you can use above mentioned technique to configure individual parts. For example, you would use CookieAuthenticationOptions to configure cookies, and JwtBearerOptions for token based authentication. Extension and individual configuration options, including an example of injecting IConfiguration into one of them, would look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public static class AuthenticationExtensions
{
public static IServiceCollection ConfigureAuthentication(this IServiceCollection services)
{
services.AddAuthentication().AddCookie().AddJwtBearer();
services.AddSingleton<IConfigureOptions<AuthenticationOptions>, ConfigureAuthenticationOptions>();
services.AddSingleton<IConfigureOptions<CookieAuthenticationOptions>, ConfigureCookieAuthenticationOptions>();
services.AddSingleton<IConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerOptions>();
return services;
}
}

public class ConfigureAuthenticationOptions : IConfigureOptions<AuthenticationOptions>
{
public void Configure(AuthenticationOptions options)
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}
}

public class ConfigureCookieAuthenticationOptions : IConfigureOptions<CookieAuthenticationOptions>
{
public void Configure(CookieAuthenticationOptions options)
{
options.ExpireTimeSpan = TimeSpan.FromHours(1);
}
}

public class ConfigureJwtBearerOptions : IConfigureOptions<JwtBearerOptions>
{
private readonly IConfiguration _configuration;

public ConfigureJwtBearerOptions(IConfiguration configuration)
{
_configuration = configuration;
}

public void Configure(JwtBearerOptions options)
{
options.Authority = _configuration.GetValue<string>("jwt:authority");
options.RequireHttpsMetadata = false;
options.IncludeErrorDetails = true;
}
}

Authorization

Configuring authorization can get quite tedious, if number or complexity of policies increases. Luckily, setup process is pretty straightforward, as the only concern is injecting configuration via AuthorizationOptions type.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class AuthorizationExtensions
{
public static IServiceCollection ConfigureAuthorization(this IServiceCollection services)
{
services.AddAuthorization();
services.AddSingleton<IConfigureOptions<AuthorizationOptions>, ConfigureAuthorizationOptions>();
return services;
}
}

public class ConfigureAuthorizationOptions : IConfigureOptions<AuthorizationOptions>
{
public void Configure(AuthorizationOptions options)
{
options.DefaultPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
}
}

OpenAPI

If you care about your REST API consumers, you are publishing service description, using OpenAPI specification. And, since we are talking about ASP.NET Core, you are probably using awesome Swashbuckle package. As with previously mentioned framework parts, this extension was designed on same principles, and can be setup in a similar fashion. Whether its generating definition file using SwaggerGenOptions, or adjusting swagger user interface via SwaggerUIOptions, everything can be broken down and put into separate types.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public static class OpenApiExtensions
{
public static IServiceCollection ConfigureOpenApi(this IServiceCollection services)
{
services.AddSwaggerGen();
services.AddSwaggerGenNewtonsoftSupport();
services.AddSingleton<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerGenOptions>();
services.AddSingleton<IConfigureOptions<SwaggerOptions>, ConfigureSwaggerOptions>();
services.AddSingleton<IConfigureOptions<SwaggerUIOptions>, ConfigureSwaggerUiOptions>();
return services;
}
}

public class ConfigureSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
{
public void Configure(SwaggerGenOptions options)
{
options.SwaggerDoc("v1", new OpenApiInfo { Title = "Demo", Version = "v1" });
}
}

public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerOptions>
{
public void Configure(SwaggerOptions options)
{
options.RouteTemplate = "swagger/{documentName}/swagger.json";
options.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
{
swaggerDoc.Servers = new[] {
new OpenApiServer {
Url = $"{httpReq.Scheme}://{httpReq.Host.Value}{httpReq.PathBase}",
Description = "Default"
}
};
});
}
}

public class ConfigureSwaggerUiOptions : IConfigureOptions<SwaggerUIOptions>
{
public void Configure(SwaggerUIOptions options)
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Demo v1");
}
}

Application Insights

How about some Azure extensions? Most of the time you will not even need them, as ASP.NET Core seamlessly integrates with most of Azure resources. However, one particular extension I recommend using are Application Insights. You want to have application telemetry under control, and this package allows you to fine tune metrics and data that will be ingested by Azure. From setup point of view, there is nothing surprising:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static class TelemetryExtensions
{
public static IServiceCollection ConfigureTelemetry(this IServiceCollection services)
{
services.AddApplicationInsightsTelemetry();
services.AddSingleton<IConfigureOptions<ApplicationInsightsServiceOptions>, ConfigureApplicationInsights>();
return services;
}
}

public class ConfigureApplicationInsights : IConfigureOptions<ApplicationInsightsServiceOptions>
{
public void Configure(ApplicationInsightsServiceOptions options)
{
options.EnableHeartbeat = true;
options.EnableAppServicesHeartbeatTelemetryModule = true;
}
}

Extras

You’ve probably gotten the gist of it by now; use extension methods to extract service definitions, and options to do actual service configuration. For completeness, let me give you the rest of commonly used ASP.NET Core features, including types used for configuration.

Application code

Last but not least, what about your own application code? I highly recommend following the same principles, in order to avoid having application setup in a single place. If you are using clean architecture, and have your code decoupled into layers, initialization could be as simple as:

1
2
3
4
5
6
7
8
9
10
11
12
public static class ApplicationExtensions
{
public static IServiceCollection ConfigureApplication(this IServiceCollection services)
{
services
.AddDomain()
.AddApplicationServices()
.AddRepositories()
.AddIntegration();
return services;
}
}

Conclusion

I have shown you one neat little trick that I use all the time, when setting up any kind of application. It is important to remember Single-responsibility principle, even for this type of code. Having Startup.cs consisting of several hundreds, or even thousands lines, is nothing unheard of. And, sadly, is an obvious code smell.

All code shown in this post was published to GitHub, as a single ASP.NET Core 6 project.

Asynchronous Job Pattern - The ASP.NET Core MVC Way

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×