DEV Community

Jiten Shahani
Jiten Shahani

Posted on

Understanding Service Registration & Keyed Services in Dependency Injection

Introduction

Dependency Injection (DI) is a powerful design pattern that helps decouple dependencies, making applications more testable, flexible, and maintainable. In .NET, the ServiceCollection provides a structured way to register dependencies, but choosing the right approach is crucial.

In this article, you'll learn:

  • Standard service registration in DI
  • Handling multiple implementations with IEnumerable<IMessageService>
  • Using keyed service registration to resolve specific implementations (requires .NET 8+ or a compatible DI container)

By understanding these techniques, you'll be able to register, resolve, and manage dependencies effectively in your applications.

Let’s dive in! 🚀

Example Dependencies

Before diving into service registration, let’s define some dependencies:


public interface IMessageService
{
    void SendMessage(string message);
}

public class EMailService : IMessageService
{
    public void SendMessage(string message) =>
        Console.WriteLine($"EMail message sent: {message}");
}

public class SMSService : IMessageService
{
    public void SendMessage(string message) =>
        Console.WriteLine($"SMS message sent: {message}");
}

public class DefaultProcessor
{
    private readonly IMessageService _messageService;

    public DefaultProcessor (IMessageService messageService)
    {
        _messageService = messageService;
    }

    public void ProcessMessage (string message)
    {
        _messageService.SendMessage (message);
    }
}

public class MessageProcessor
{
    private readonly IEnumerable<IMessageService> _messageServices;
    private readonly IServiceProvider _serviceProvider;

    public MessageProcessor (IEnumerable<IMessageService> messageServices, IServiceProvider serviceProvider)
    {
        _messageServices = messageServices;
        _serviceProvider = serviceProvider;
    }

    public void ProcessMessage (string message)
    {
        // Iterate through all the services registered as IMessageService.
        foreach (var service in _messageServices)
            service.SendMessage (message);
    }

    public void ProcessMessage (string message, MyServiceType serviceType)
    {
        var service = _serviceProvider.GetRequiredKeyedService<IMessageService> (serviceType);
        service.SendMessage (message);
    }
}

public enum MyServiceType
{
    EMail,
    SMS
}

Enter fullscreen mode Exit fullscreen mode

These services implement IMessageService, allowing us to register them in DI. Now, let’s explore different registration methods.

Standard Service Registration in Dependency Injection

The most common way to register services in DI is using AddSingleton, AddScoped, or AddTransient. Here, we register multiple services against the same interface.

Example


ServiceProvider serviceProvider = new ServiceCollection()
    .AddSingleton<IMessageService, EMailService>()  // First registration
    .AddSingleton<IMessageService, SMSService>()   // Last registration overrides previous ones
    .AddSingleton<DefaultProcessor>()
    .AddSingleton<MessageProcessor>()
    .BuildServiceProvider();

// Resolving default service (only last registered IMessageService is used)
DefaultProcessor defaultProcessor = serviceProvider.GetRequiredService<DefaultProcessor>();
defaultProcessor.ProcessMessage("Hello, Default behavior. Dependency Injection!");

Enter fullscreen mode Exit fullscreen mode

Issue

When multiple services are registered under the same interface (IMessageService), only the last registered service (SMSService) will be used. This may not always be the intended behavior.

Solution

To resolve multiple implementations, we can inject IEnumerable<IMessageService>.

Handling Multiple Implementations with IEnumerable<IMessageService>

If we register multiple implementations of IMessageService, we can inject IEnumerable<IMessageService> to iterate through all implementations.

Example


MessageProcessor messageProcessor = serviceProvider.GetRequiredService<MessageProcessor>();
messageProcessor.ProcessMessage("Hello, Dependency Injection!");

Enter fullscreen mode Exit fullscreen mode

Inside MessageProcessor, we loop through all available implementations


public class MessageProcessor
{
    private readonly IEnumerable<IMessageService> _messageServices;

    public MessageProcessor(IEnumerable<IMessageService> messageServices)
    {
        _messageServices = messageServices;
    }

    public void ProcessMessage(string message)
    {
        foreach (var service in _messageServices)
            service.SendMessage(message);
    }
}

Enter fullscreen mode Exit fullscreen mode

Result

Both EMailService and SMSService will process the message.

Use Case

This technique is useful when all implementations need to be executed, such as logging to multiple outputs or notifying multiple users.

Using Keyed Service Registration

Keyed service registration allows developers to resolve specific implementations based on a unique key.

Example


ServiceProvider serviceProvider = new ServiceCollection()
    .AddKeyedSingleton<IMessageService, EMailService>(MyServiceType.EMail)
    .AddKeyedSingleton<IMessageService, SMSService>(MyServiceType.SMS)
    .AddSingleton<MessageProcessor>()
    .BuildServiceProvider();

MessageProcessor messageProcessor = serviceProvider.GetRequiredService<MessageProcessor>();

// Resolving specific services using keys
messageProcessor.ProcessMessage("Hello, Dependency Injection!", MyServiceType.EMail);
messageProcessor.ProcessMessage("Hello, Dependency Injection!", MyServiceType.SMS);

Enter fullscreen mode Exit fullscreen mode

Advantages of Keyed Services

  • Explicit Control – Developers choose which implementation to use at runtime.
  • Improved Testability – Easier to test specific scenarios.
  • Better Performance – Avoid unnecessary object creation.

Implementation in MessageProcessor


public class MessageProcessor
{
    private readonly IServiceProvider _serviceProvider;

    public MessageProcessor(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void ProcessMessage(string message, MyServiceType serviceType)
    {
        var service = _serviceProvider.GetRequiredKeyedService<IMessageService>(serviceType);
        service.SendMessage(message);
    }
}

Enter fullscreen mode Exit fullscreen mode

Result

Depending on the passed key (MyServiceType.EMail or MyServiceType.SMS), the correct service is resolved.

Use Case

Useful in multi-tenant applications, message routing, and strategy-based pattern implementations.

Common Pitfalls

While using Dependency Injection, developers often make mistakes that affect performance and maintainability. Here are some pitfalls to avoid:

Common Pitfalls

Registering Multiple Services Without IEnumerable<IMessageService>

  • Issue: If multiple services are registered under the same interface (IMessageService), only the last registered service is used, leading to unexpected behavior.
  • Example: Registering both EMailService and SMSService under IMessageService but only one gets resolved.
  • Solution: Inject IEnumerable<IMessageService> to process all registered services.

Overusing Singleton Dependencies

  • Issue: Using too many singletons may introduce shared state, causing unintended side effects in multi-threaded applications.
  • Example: A singleton logging service storing mutable state incorrectly.
  • Solution: Ensure singleton services are truly stateless, or use locks for thread safety.

Injecting Too Many Dependencies Into a Single Class

  • Issue: Having too many constructor parameters may indicate poor design, making maintenance harder.
  • Example: A service requiring 10 injected dependencies becomes unwieldy.
  • Solution: Consider grouping related dependencies into helper classes or using facade patterns.

Misconfigured Dependency Lifetimes

  • Issue: Using singleton dependencies for stateful objects can lead to unintended behaviors.
  • Example: A database connection should not be a singleton. It might retain stale data.
  • Solution: Use Scoped lifetime for request-bound objects and Transient for short-lived services.

Using Service Locator Instead of Direct Injection

  • Issue: Fetching dependencies manually via _serviceProvider.GetRequiredService<T>() inside services creates hidden dependencies, reducing testability.
  • Example: A class resolving dependencies dynamically instead of having them injected.
  • Solution: Prefer constructor injection for better readability and testability.
  • Exception: Using serviceProvider.GetRequiredService<T>() inside middleware or entry points is acceptable.

Not Disposing Dependencies Properly

  • Issue: Forgetting to dispose of IDisposable services results in resource leaks.
  • Example: Not disposing DbContext in an EF Core application leads to excessive memory consumption.
  • Solution: Use Scoped dependencies for disposable objects and implement IAsyncDisposable where necessary.

Best Practices

  • Choose the Right Lifetime Scope – Use Transient for lightweight services, Scoped for request-specific objects, and Singleton for shared dependencies.
  • Keep Dependencies Minimal – Avoid excessive injection; group related dependencies logically.
  • Use Constructor Injection Over Property Injection – Constructor injection ensures dependencies exist when the object is instantiated.
  • Use IoC Containers Efficiently – Register services properly, avoid redundant registrations, and ensure correct dependency resolution.
  • Test Dependencies with Mocking Frameworks – Use Moq or FakeItEasy to create mock dependencies instead of relying on real implementations.

Key Takeaways

  • Standard DI registration uses the last-registered service for a given interface.
  • Use IEnumerable<T> to resolve and use all registered implementations of an interface.
  • Keyed services (in .NET 8+ or compatible containers) allow resolving a specific implementation by key.
  • Always choose the right service lifetime (Transient, Scoped, Singleton) for your scenario.
  • Avoid common pitfalls: hidden dependencies, overusing singletons, and misconfigured lifetimes.
  • Prefer constructor injection for clarity and testability.

Further Reading

Final Thoughts

Understanding common pitfalls and best practices ensures a smooth and efficient DI setup. Avoid hidden dependencies, misconfigured lifetimes, and over-injected classes to keep your application scalable, maintainable, and testable. By following DI best practices, developers can write cleaner, more efficient, and modular applications while avoiding technical debt.

Understanding service registration techniques in Dependency Injection is crucial for maintainable, scalable, and flexible applications. This article explored:

  • Standard DI registration, showing how last-registered services override previous ones.
  • Using IEnumerable<T>, allowing multiple implementations to coexist.
  • Leveraging Keyed Services, enabling explicit control over service resolution.

Choosing the right DI strategy depends on your application needs. Whether you're building an Asp Net Core API, a background service, or a multi-tenant system, understanding how services are registered and resolved ensures better architecture and performance.

Start experimenting with IEnumerable and Keyed Services, and you’ll have a more flexible, maintainable DI setup in your projects.

About the Author

Hi, I’m Jiten Shahani, a passionate developer with a strong background in API development and C# programming. Although I’m new to .NET, my journey into learning ASP .NET Core began in December 2024, driven by a desire to build scalable and maintainable applications.

Through my exploration of dependency injection, I’ve realized how crucial it is for writing flexible, testable, and loosely coupled code. This article is a reflection of my learning process, aimed at helping fellow developers, especially beginners, understand the different ways to inject dependencies effectively in C#.

Feel free to connect with me to exchange ideas and learn together!

Top comments (0)