Skip to main content

0026 - Adopt TryAdd Dependency Injection Overloads

ID:ADR-0026
Status:PROPOSED
Published:2025-05-30

Context and Problem Statement

Microsoft.Extensions.DependencyInjection (the DI provider we use) has a "last one wins" behavior -- this means that if you inject two services of the same service type, the last implementation that was registered wins. For example:

services.AddSingleton<IMyService, ImplementationOne>();
// Somewhere later on in the codebase
services.AddSingleton<IMyService, ImplementationTwo>();

When a service or controller injects IMyService they will be getting ImplementationTwo even though ImplementationOne does still exist in the service container. It exists in the container still because if you were to instead inject IEnumerable<IMyService> you would receive an enumerable containing 2 services, one for each implementation that was registered. This is the behavior you want sometimes but it is much rarer to inject an enumerable of services as opposed to injecting just a single service, and is where the TryAdd overloads on IServiceCollection (in the Microsoft.Extensions.DependencyInjection.Extensions namespace) come in handy. One can more explicitly declare the expected usage of a service during service configuration time. The above example could instead be written like:

services.TryAddSingleton<IMyService, ImplementationOne>();
// Somewhere later on in the codebase
services.TryAddSingleton<IMyService, ImplementationTwo>();

Now when you inject IMyService you would instead be receiving ImplementationOne and if you injected IEnumerable<IMyService> you would only get an enumerable with a single instance and it would also be ImplementationOne. There would also only be a single ServiceDescriptor registered in the container. What TryAdd?Keyed?{Singleton|Scoped|Transient} does under the hood is check if there has already been a service with type IMyService (and key if using a keyed service) registered. If one has, it will skip adding another entry with its given implementation, but if one has not already been added, it will add it.

If you do want multiple services for a given service type (for using with IEnumerable<IMyService>) then you should likely be using the TryAddEnumerable overload. If you specifically wanted multiple implementations to be able to be injected you'd structure it like:

services.TryAddEnumerable(ServiceDescriptor.Singleton<IMyService, ImplementationOne>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMyService, ImplementationTwo>());

TryAddEnumerable won't add the service to the container if the service type and implementation type are the same. This leads you to one of the three scenarios where you would specifically not want to use the TryAdd overloads.

Scenario One

If you did want to use inject an IEnumerable<IMyService> and wanted that list to have multiple services of the same implementation.

Scenario Two

You know for an absolute fact that you are the first to register a given service type. In this case it is more acceptable to not use TryAdd overloads although it likely doesn't hurt anything and in favor of not breaking the rules, it's still encouraged to use TryAdd.

Scenario Three

If you know you are the last to register a service and you need to override whatever implementation might have previously been registered. The ideal place to make these decisions is earlier in DI instead of after but until TryAdd is fully adopted the only place to get it to work is at the very end. You should be very careful while doing this and include a justification for each such usage. You likely even want to go a step further and manually remove the service descriptor that you don't want and then inject yours into it. For example:

services.TryAddSingleton<IMyService, DefaultImplementation>();
// Later on in execution order
services.Remove(
services.Single(sd => sd.ServiceType == typeof(IMyService));
);
services.AddSingleton<IMyService, MySpecialImplementation>();

This is another instance where you now know that there isn't another registration of IMyService elsewhere in container and so once again it might just be worth it to do TryAddSingleton in order to limit the amount of times you are breaking the rules.

The benefits to using TryAdd on all your services is that you can create an Add[Feature] method to add all the services needed to make your feature work and that method can be called many times with no ill-effect to the system. This means that if someone else builds a feature on top of yours they also can call AddYourFeature in their service registration, which is generally a good practice to do so that you don't get a runtime error about a missing dependency; it is also a practice followed throughout the ASP.NET Core repo as well as many other libraries that integrate with DI. For example, Data Protection calls services.AddOptions even though it's highly likely that something else in the application has already called it and their usage doesn't actually add any service. This pattern generally makes testing this method easier, as it is a "batteries included" method and also helps show a clear dependency graph. There are a few services that are allowed to not be explicitly added as they are expected to always be included in the host -- those services are ILogger<>, ILoggerFactory, IConfiguration, and IHostEnvironment.

Considered Options

  • Ad-hoc usage - Where we are today, the TryAdd overloads are allowed to be used and are used occasionally throughout the codebase but they is no outside encouragement to use them.
  • Encourage usage - Start encouraging usage through team training and encouragement to use them in code reviews but don't make any automatic check to enforce usage.
  • Enforce usage - Start enforcing usage of TryAdd overloads by adding the Microsoft.CodeAnalysis.BannedApiAnalyzers NuGet package and adding the non-TryAdd overloads to the list of banned APIs. If you believe your usage of the API is valid you would add a #pragma warning disable and a comment explaining the justification.
  • Disallow usage - There doesn't seem like a good reason to do this as there are more explicit versions of the non-TryAdd overloads. If you want to use them you should be allowed to.

Decision Outcome

Chosen option: Encourage usage.

Positive Consequences

  • More explicit intention.
  • Built-in dependency graph.
  • Easier ability for the host to make overarching decisions.
  • A single project that bootstraps multiple services is much easier.

Negative Consequences

  • New paradigm.
  • A migration to TryAdd if done incorrectly could break things.

Plan

The plan to encourage the usage will be to update our C# style guide with the recommendation to begin using the TryAdd overloads (the document should explain when you want to use each one). The coding guidelines can be part of a newly-added document about general dependency injection guidelines for .NET here at Bitwarden.

A one-time recorded learning session will also be hosted; the session will go over the new docs, show off migrating existing service registrations to using the TryAdd overloads, and host a QnA.

The migrations done in the above session as well as a few others will be made so that there are good examples in the codebase to point to for the new preference.