Dependency Container Separation in ASP.NET Core

The developers of AspNet Core (hereinafter we are talking about AspNet current versions: 6 and 7, but may be applicable to earlier versions) are well aware that the Dependency Injection mechanism is built into this framework from the very beginning and permeates it through and through. And this greatly simplifies the work with dependencies and immediately introduces development into the ideologically correct direction. Moreover, AspNet itself includes a pretty decent default DI container developed by Microsoft, which allows you to refuse to use third-party solutions. In any case, in the absence of very specific requirements.

Problem (or rather feature) of ServiceProvider

The main feature of the built-in ServiceProvider – it is single and global. Yes, it has scopes, but they are only used to manage the lifetime of dependencies created, for example “within the processing of a specific HTTP request”. But, for example, if you need to pass another version of the basic services settings to some part of the HTTP pipeline (for example, using the AddXXX methods), then you have a problem.

I have a web application that works with a database through this simple interface:

public interface IDbProvider
{
	DbConnection CreateConnection();
}

I register a specific implementation of the provider at the pipeline setup stage, the application components receive it from the DI container, everything works like clockwork. But then I had a task to add a copy of the application functionality, for example, to the URI /test, which should work with its own database separate from the main application. The task was complicated by the fact that the same database was used in the method handlers AddAuthentication(). In other words, it was necessary to isolate a part of the HTTP pipeline, passing it its dependencies, including system dependencies, like authentication.

It seems that you can add a second Kestrel application with your own settings and not bother. In practice, the matter was complicated by the fact that the users of the application were different, sometimes very small organizations, with a very small IT service or even with a single incoming administrator. There is simply no one to set up a new instance on a different port or reverse proxy. The solution should be as simple as possible: launch MSI with a new version of the application, click next-next-next and that’s it, no unnecessary gestures and settings.

Finding a solution

The world search mind did not give much results, only one was found article, which more or less described my problem and a possible way to solve it. Of course, the absence of articles suggests that the problem is rare and, therefore, of little interest to anyone. And it’s probably easier to make a separate application. But here natural curiosity has already won.

At first I decided to take the solution from the mentioned article as a sample. But it was initially stinky: feints with ears with copying the container seemed to work, but small glitches crawled somewhere and in general it all looked completely different comme il faut. Having pushed around with the code, I already wanted to quit the idea, but then two memories surfaced that accidentally settled in the attic after reading the documentation for the DI container autofacwhich was once used for another project.

The first was that AspNet allows you to replace the standard DI container with any other compatible one. autofac just one of those. With a couple of lines of code, it is easily integrated into AspNet, completely and seamlessly replacing the built-in ServiceProvider.

The second important feature of this DI container is much more powerful support for child scopes, including the ability to add dependencies to them, and even redefine dependencies previously registered in parent scopes. Details are good as always documented.

Putting the puzzle together

The two listed features autofac And previously mentioned article was enough to solve the problem.

After some exercises, an extension method is born MapPartition()which is analogous to the standard Map() with the only difference: the part of the pipeline that runs inside the handler MapPartition()runs in a separate child scope of the DI container, with the ability to add or override dependencies.

The full text of the extension method:

public static IApplicationBuilder MapPartition(this IApplicationBuilder app, PathString pathMatch,
	bool preserveMatchedPathSegment, Action<IServiceCollection, IServiceProvider> configureServices, Action<IApplicationBuilder> configuration)
{
	// 1. Extract parent scope from the existing container
	var parentScope = app.ApplicationServices.GetRequiredService<ILifetimeScope>();
		
	// 2. Сreate child Autofac ILifetimeScope
	var childScopeFactory = new AutofacChildLifetimeScopeServiceProviderFactory(parentScope);

	// 3. Register new services
	var services = new ServiceCollection();
	serviceConfiguration(services, app.ApplicationServices);
	var serviceAdapter = childScopeFactory.CreateBuilder(services);

	// 4. Сreate a new pipeline branch
	var branchBuilder = app.New();
	// replace ServiceProvider in the to the scoped one
	branchBuilder.ApplicationServices = childScopeFactory.CreateServiceProvider(serviceAdapter);
	branchBuilder.Use(
		async (c, next) =>
		{
			c.RequestServices = branchBuilder.ApplicationServices;
			await next();
		});
	configuration(branchBuilder);
	var branch = branchBuilder.Build();

	// 5. Link the new branch to the original pipeline
	var options = new MapOptions
	{
		Branch = branch,
		PathMatch = pathMatch,
		PreserveMatchedPathSegment = preserveMatchedPathSegment
	};
	return app.Use(next => new MapMiddleware(next, options).Invoke);
}

From the comments it is already clear what the method does:

  1. Retrieves a reference to the current scope autofac from a container.

  2. Creates a new child scope autofac.

  3. Registers new dependencies within the created scope.

  4. Creates a new pipeline branch wrapped in middleware that replaces the parent scope with the child scope in the properties HttpContext.

  5. Attaches the created pipeline branch to the main one using the standard MapMiddleware.

Using the generated method:

internal static class Program
{
	private static void ConfigureWebHost(IWebHostBuilder builder)
	{
		// add global services to the container
		builder.ConfigureServices(
			services =>
			{
				services.AddHttpContextAccessor();
				services.AddControllersWithViews();
				// register main database
				services.AddSingleton<IDbProvider, MainDbProvider>();
			});
			
		builder.Configure(
			(context, app) =>
			{
				app.MapPartition("/test", false,
					// register test database
					(services, parentServices) => services.AddSingleton<IDbProvider, TestDbProvider>(),
					appPart => 
					{
						// test partition
						appPart.UseRouting();
						appPart.UseEndpoints(endpoints =>
						{
							endpoints.MapControllerRoute(
								name: "default",
								pattern: "{controller=Home}/{action=Index}/{id?}");
						});
					});

				// main partition
				app.UseRouting();
				app.UseEndpoints(endpoints =>
				{
					endpoints.MapControllerRoute(
						name: "default",
						pattern: "{controller=Home}/{action=Index}/{id?}");
				});
			});
	}

	public static void Main(string[] args)
	{
		var builder = Host.CreateDefaultBuilder(args);
		// replace default ServiceProvider
		builder.UseServiceProviderFactory(new AutofacServiceProviderFactory());
		builder.ConfigureWebHostDefaults(ConfigureWebHost);
		var host = builder.Build();
		host.Run();
	}
}

In the example above, the call MapPartition("/test", false, ...) wraps all calls with a path prefix /test into a separate pipeline branch. The prefix is ​​removed, which allows you to use all the same application controllers, but with a different set of dependencies thanks to a dedicated child scope autofac.

Conclusion

The solution we found for splitting the DI container turned out to be quite workable, it has been working for a year in real projects without any problems.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *