Achieve Runtime Service Switching in ASP.NET Core DI Pattern

Dependency Injection (DI) is one of the core architectural patterns in modern ASP.NET Core applications. It promotes loose coupling, improves testability, and simplifies service management by allowing dependencies to be registered and resolved automatically through the built-in DI container.

In most applications, service registrations are configured once during application startup and remain unchanged throughout the application's lifetime. However, certain advanced scenarios require the ability to replace service registrations dynamically at runtime. Examples include multi-tenant systems, feature toggles, plugin-based architectures, environment-specific implementations, runtime strategy switching, and A/B testing.

Although the default ASP.NET Core DI container is immutable after application startup (after builder.Build()), developers can still implement dynamic service replacement patterns using factories, keyed services, scoped providers, or custom resolution strategies. Understanding these techniques helps build more flexible and extensible applications without compromising maintainability.

In this article, we will explore how dynamic service replacement works in ASP.NET Core, why the built-in container restricts runtime modifications, and the recommended approaches to achieve runtime service switching effectively.

Achieve Runtime Service Switching in ASP.NET Core DI Pattern

Getting Started

In ASP.NET Core, services are typically registered during application startup using the builder.Services collection, where developers define how interfaces and implementations should be mapped along with their lifetimes, such as Singleton, Scoped, or Transient.

Example:

builder.Services.AddScoped<IMessageService, EmailService>();

Once the application calls builder.Build(), the framework creates an internal service container responsible for resolving dependencies throughout the application's lifetime.

When a controller, middleware, or service requires a dependency, ASP.NET Core automatically injects the appropriate instance through constructor injection. This approach reduces tight coupling between components, improves maintainability, and makes applications easier to test and extend. By centralizing object creation within the DI container, ASP.NET Core encourages cleaner architecture and more modular application design.

Why ASP.NET Core DI Does Not Allow Direct Replacement

ASP.NET Core does not allow direct replacement of service registrations at runtime because its built-in Dependency Injection (DI) container is designed to be immutable after the application starts. Once the application calls builder.Build(), the framework creates an optimized internal service provider that manages dependency resolution, object lifetimes, and disposal tracking efficiently.

Allowing services to be replaced dynamically after this stage could introduce thread-safety issues, inconsistent object states, memory leaks, and unpredictable application behavior, especially in high-concurrency web environments. The container also caches service resolution strategies internally to improve performance, and modifying registrations at runtime would invalidate those optimizations.

For these reasons, ASP.NET Core prioritizes stability, predictability, and performance over runtime mutability, encouraging developers to use alternative patterns such as factories, keyed services, or custom resolvers when dynamic behavior is required.

Why ASP.NET Core DI Is Immutable

In ASP.NET Core, the Dependency Injection (DI) container is designed to be immutable after the application starts to ensure the system remains stable, predictable, and high-performing throughout its runtime. When the application is built using builder.Build(), the framework constructs a fully optimized service provider that caches service descriptors, resolves dependency graphs, and manages object lifetimes efficiently.

Allowing changes to service registrations after this point would break these internal optimizations and could introduce serious issues such as race conditions in multi-threaded environments, inconsistent object lifetimes, and unreliable disposal of resources.

Since web applications typically handle concurrent requests, immutability ensures thread safety by guaranteeing that service definitions remain constant for all requests. This design choice prioritizes performance, reliability, and architectural consistency, encouraging developers to use patterns like factories, keyed services, or custom resolvers when runtime flexibility is required instead of modifying the container itself.

Why Developers Need Runtime Replacement

In ASP.NET Core applications, services are usually configured once during startup, but many modern architectures require the ability to switch implementations dynamically while the application is running.

Runtime service replacement is especially useful in multi-tenant systems where different customers may require different service providers or business rules.

It is also commonly used with feature flags and A/B testing, where new implementations are gradually enabled for selected users without redeploying the application.

Plugin-based architectures rely on dynamic service replacement to load or unload modules at runtime, while environment-specific configurations may switch services based on development, staging, or production requirements.

In cloud-native systems, applications may dynamically replace services with fallback implementations during outages or high-traffic situations to improve resiliency.

For example, a payment processing module may switch between Stripe and PayPal depending on tenant preferences or service availability. These scenarios demonstrate why runtime service replacement is an important technique for building flexible, scalable, and highly adaptable enterprise applications.

Approaches to Replace Services Dynamically

Since ASP.NET Core does not support modifying service registrations directly after the application has been built, developers must rely on alternative design patterns to achieve dynamic behavior at runtime. Instead of replacing services inside the Dependency Injection (DI) container itself, modern ASP.NET Core applications typically implement runtime switching through factories, keyed services, scoped service providers, custom resolvers, or strategy-based patterns.

These approaches allow applications to select different implementations dynamically while still preserving the stability, performance, and thread safety of the built-in DI container. Choosing the right approach depends on the application's complexity, scalability requirements, and the level of runtime flexibility needed. Understanding these patterns is essential for building adaptable systems that can support multi-tenancy, feature toggles, plugin architectures, and environment-specific behaviors efficiently.

Approaches
  1. Factory Pattern (Recommended)
  2. Using IServiceProvider
  3. Keyed Services
  4. Creating Child Scopes Dynamically
  5. Custom Service Resolver
Using Factory Pattern

The following example demonstrates the Factory Pattern implementation in ASP.NET Core for dynamically selecting a service at runtime. It includes a common interface, multiple concrete service implementations, and a factory class responsible for resolving the appropriate service based on input.

The code also shows how these services are registered in the dependency injection container and how the factory is used within a controller to invoke the selected implementation.

Define the service contract

public interface IMessageService
{
  string SendMessage();
}

Create multiple implementations

public class EmailService : IMessageService
{
  public string SendMessage()
  {
    return "Message sent via Email";
  }
}
public class SmsService : IMessageService
{
  public string SendMessage()
  {
    return "Message sent via SMS";
  }
}

Create the Factory

public class MessageServiceFactory
{
  private readonly IServiceProvider _serviceProvider;
  public MessageServiceFactory(IServiceProvider serviceProvider)
  {
    _serviceProvider = serviceProvider;
  }
  public IMessageService GetService(string type)
  {
    return type.ToLower() switch
    {
      "email" => _serviceProvider.GetRequiredService<EmailService>(),
      "sms"   => _serviceProvider.GetRequiredService<SmsService>(),
      _ => throw new ArgumentException("Invalid message type")
      };
    }
  }

This factory decides which implementation to return at runtime.

Register services in DI container

 var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<EmailService>();
builder.Services.AddTransient<SmsService>();
builder.Services.AddSingleton<MessageServiceFactory>();
var app = builder.Build();
Use the Factory in Controller
[ApiController]
[Route("api/[controller]")]
public class MessageController : ControllerBase
{
  private readonly MessageServiceFactory _factory;
  public MessageController(MessageServiceFactory factory)
  {
    _factory = factory;
  }
  [HttpGet("{type}")]
  public IActionResult Send(string type)
  {
    var service = _factory.GetService(type);
    return Ok(service.SendMessage());
  }
}
How it works
At runtime, the factory:
  • Reads the requested type (email or sms)
  • Resolves the correct implementation from DI
  • Returns the selected service without modifying the container
Why IServiceProvider was used in the factory
  • Resolve services dynamically at runtime
  • Keep the factory flexible without hard-coding new EmailService()
  • Respect Dependency Injection rules in ASP.NET Core
  • Allow lifetimes (Transient/Scoped/Singleton) to be handled by the container
So the factory acts as a decision layer, while IServiceProvider acts as a resolution engine.

Using Keyed Services

Here’s a clean Keyed Services example for ASP.NET Core (introduced in .NET 8), which lets you register and resolve services by a key without writing a custom factory.

Define the interface

public interface IMessageService
{
  string SendMessage();
}

Create implementations

public class EmailService : IMessageService
{
  public string SendMessage()
  {
    return "Message sent via Email";
  }
}
public class SmsService : IMessageService
{
  public string SendMessage()
  {
    return "Message sent via SMS";
  }
}

Register Keyed Services

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddKeyedTransient<IMessageService, EmailService>("email");
builder.Services.AddKeyedTransient<IMessageService, SmsService>("sms");
var app = builder.Build();

Resolve by Key in Controller

[ApiController]
[Route("api/[controller]")]
public class MessageController : ControllerBase
{
  private readonly IServiceProvider _serviceProvider;
  public MessageController(IServiceProvider serviceProvider)
  {
    _serviceProvider = serviceProvider;
  }
  [HttpGet("{type}")]
  public IActionResult Send(string type)
  {
    var service = _serviceProvider
    .GetRequiredKeyedService<IMessageService>(type);
    return Ok(service.SendMessage());
  }
}

Alternative: Constructor Injection (Cleaner Approach)

public class MessageController : ControllerBase
{
  private readonly IMessageService _emailService;
  public MessageController(
  [FromKeyedServices("email")] IMessageService emailService)
  {
    _emailService = emailService;
  }
}
Why Keyed Services are useful
  • No custom factory needed
  • Built-in support in ASP.NET Core
  • Cleaner than IServiceProvider switches
  • Works well for multi-implementation scenarios (e.g., payment providers, messaging systems)

Best Practices

  • Prefer factories over container mutation
  • Keep runtime switching centralized
  • Use interfaces consistently
  • Avoid service locator anti-pattern
  • Use keyed services in .NET 8+
  • Log runtime service selection

Summary

In ASP.NET Core, the built-in Dependency Injection (DI) system is intentionally designed to be immutable after the application starts. Once the service container is built, its registrations and lifetimes are fixed to ensure thread safety, predictable behavior, and high performance across concurrent web requests. This prevents runtime modifications that could lead to inconsistent object graphs, lifecycle issues, or unstable application behavior.

Instead of changing registrations dynamically, ASP.NET Core encourages developers to use design patterns such as factories, keyed services, and custom resolvers to achieve flexibility in selecting implementations at runtime. This approach maintains the stability of the DI container while still allowing applications to adapt to changing business requirements.

Thanks

Kailash Chandra Behera

I am an IT professional with over 13 years of experience in the full software development life cycle for Windows, services, and web-based applications using Microsoft .NET technologies.

Previous Post Next Post

نموذج الاتصال