Skip to content

Commit 318e8ea

Browse files
committed
Add TryAdd ADR
1 parent cb1cecf commit 318e8ea

File tree

2 files changed

+148
-1
lines changed

2 files changed

+148
-1
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
---
2+
adr: "0026"
3+
status: "Proposed"
4+
date: 2025-05-30
5+
tags: [server]
6+
---
7+
8+
# 0026 - Adopt `TryAdd` Dependency Injection Overloads
9+
10+
<AdrTable frontMatter={frontMatter}></AdrTable>
11+
12+
## Context and Problem Statement
13+
14+
`Microsoft.Extensions.DependencyInjection` (the DI provider we use) has last one wins behavior. This
15+
means that if you inject two services of the same service type the last implementation that was
16+
registered. For example:
17+
18+
```csharp
19+
services.AddSingleton<IMyService, ImplementationOne>();
20+
// Somewhere later on in the codebase
21+
services.AddSingleton<IMyService, ImplementationTwo>();
22+
```
23+
24+
when a service or controller inject `IMyService` they will be getting `ImplementationTwo` even
25+
though `ImplementationOne` _does_ still exist in the service container. It exists in the container
26+
still because if you were to instead inject `IEnumerable<IMyService>` you would receive an
27+
enumerable containing 2 services, one for each implementation that was registered. This **is** the
28+
behavior you want sometimes but it is much rarer to inject an enumerable of services as opposed to
29+
injecting just a single service. This is where the [`TryAdd`][try-add-definitions] overloads on
30+
`IServiceCollection` (in the `Microsoft.Extensions.DependencyInjection.Extensions` namespace) come
31+
in handy. They allow you to more explicitly declare the expected usage of a service during service
32+
configuration time. Take the above example, it could instead be written like:
33+
34+
```csharp
35+
services.TryAddSingleton<IMyService, ImplementationOne>();
36+
// Somewhere later on in the codebase
37+
services.TryAddSingleton<IMyService, ImplementationTwo>();
38+
```
39+
40+
Now when you inject `IMyService` you would instead be receiving `ImplementaionOne` and if you
41+
injected `IEnumerable<IMyService>` you would only get an enumerable with a single instance and it
42+
would also be `ImplementationOne`. There would also only be a single `ServiceDescriptor` registered
43+
in the container. What `TryAdd?Keyed?{Singleton|Scoped|Transient}` does under the hood is checks if
44+
there has already been a service with type `IMyService` (and key if using a keyed service)
45+
registered. If one has, it will skip adding another entry with its given implementation. But if one
46+
has not already been added, it will add it.
47+
48+
If you do want multiple services for a given service type (for using with `IEnumerable<IMyService>`)
49+
then you should likely be using the `TryAddEnumerable` overload. So if you specially wanted multiple
50+
implementation to be able to be injected you'd structure it like this:
51+
52+
```csharp
53+
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMyService, ImplementationOne>());
54+
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMyService, ImplementationTwo>());
55+
```
56+
57+
`TryAddEnumerable` won't add the service to the container if the service type **and** implementation
58+
type are different. This leads you to one of the three scenarios where you would specifically
59+
**not** want to use the `TryAdd` overloads, if you did want to use inject an
60+
`IEnumerable<IMyService>` **and** wanted that list to have multiple services of the same
61+
implementation.
62+
63+
The second scenario is you know for an absolute fact that you are the first to register a given
64+
service type. In this case it is more acceptable to not use `TryAdd` overloads although it likely
65+
doesn't hurt anything and in favor of not breaking the rules, it's still encouraged to use `TryAdd`.
66+
67+
The third scenario would be if you know you are the last to register a service and you need to
68+
override whatever implementation might have previously been registered. The ideal place to make
69+
these decisions is earlier in DI instead of after but until `TryAdd` is fully adopted the only place
70+
to get it to work is at the very end. You should be very careful while doing this and include a
71+
justification for each such usage. You likely even want to go a step further and manually remove the
72+
service descriptor that you don't want and then inject yours into it. For example:
73+
74+
```csharp
75+
services.TryAddSingleton<IMyService, DefaultImplementation>();
76+
// Later on in execution order
77+
services.Remove(
78+
services.Single(sd => sd.ServiceType == typeof(IMyService));
79+
);
80+
services.AddSingleton<IMyService, MySpecialImplementation>();
81+
```
82+
83+
This is another instance where you now know that there isn't another registration of `IMyService`
84+
elsewhere in container and so once again it might just be worth it to do `TryAddSingleton` in order
85+
to limit the amount of times you are breaking the rules.
86+
87+
The benefits to using `TryAdd` on all your services is that you can create a `Add[Feature]` method
88+
to add all the services needed to make your feature work and that method can be called many times
89+
with no ill-effect to the system. This means that if someone else builds a feature on top of yours
90+
they also can call `AddYourFeature` in their service registration. This is generally a good practice
91+
to do so that you don't get a runtime error about a missing dependency. This is a practice followed
92+
throughout the [ASP.NET Core repo][aspnetcore-repo] as well as many other libraries that integrate
93+
with DI. For example, data protection calls [`services.AddOptions`][add-options-example] even though
94+
it's highly likely that something else in the application has already called it and their usage
95+
doesn't actually add any service. This pattern generally makes testing this method easier, as it is
96+
a batteries included method and it also helps show a clear dependency graph. There are a few
97+
services that are allowed to not be explicitly added as they are expected to always be included in
98+
the host. Those services are `ILogger<>`, `ILoggerFactory`, `IConfiguration`, and
99+
`IHostEnvironment`.
100+
101+
## Considered Options
102+
103+
- **Ad-hoc usage** - Where we are today, the `TryAdd` overloads are allowed to be used and are used
104+
occasionally throughout the codebase but they is no outside encouragement to use them.
105+
- **Encourage usage** - Start encouraging usage through team training and encouragement to use them
106+
in code reviews but don't make any automatic check to enforce usage.
107+
- **Enforce usage** - Start enforcing usage of `TryAdd` overloads by adding the
108+
`Microsoft.CodeAnalysis.BannedApiAnalyzers` nuget package and adding the non-`TryAdd` overloads to
109+
the list of banned APIs. If you believe your usage of the API is valid you would add a
110+
`#pragma warning disable` and a comment explaining the justification.
111+
- **Disallow usage** - There doesn't seem like a good reason to do this, they are more explicit
112+
versions of the non-`TryAdd` overloads. If you want to use them you should be allowed to.
113+
114+
## Decision Outcome
115+
116+
Chosen option: **Encourage usage**.
117+
118+
### Positive Consequences
119+
120+
- More explicit intention.
121+
- Built-in dependency graph.
122+
- Easier ability for the host to make overarching decisions.
123+
- A single project that bootstraps multiple services is much easier.
124+
125+
### Negative Consequences
126+
127+
- New paradigm.
128+
- A migration to `TryAdd` if done incorrectly could break things.
129+
130+
### Plan
131+
132+
The plan to encourage the usage will be to update our C# style guide with the recommendation to
133+
begin using the `TryAdd` overloads, the document should explain when you want to use each one. The
134+
coding guidelines can be part of a newly added document about general dependency injection
135+
guidelines for .NET here at Bitwarden.
136+
137+
A one time recording learning session will also be hosted, the session will go over the new docs,
138+
show off migrating existing service registrations to using the `TryAdd` overloads, and host a QnA.
139+
140+
The migrations done in the above session as well as a few others will be made so that there are good
141+
examples in the codebase to point to for the new preference.
142+
143+
[try-add-definitions]:
144+
https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.extensions.servicecollectiondescriptorextensions?view=net-9.0-pp
145+
[aspnetcore-repo]: https://github.com/dotnet/aspnetcore
146+
[add-options-example]:
147+
https://github.com/dotnet/aspnetcore/blob/b7606293a7146cfeb5b060340521355a0780d2d8/src/DataProtection/DataProtection/src/DataProtectionServiceCollectionExtensions.cs#L37

docs/contributing/code-style/csharp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ It may sometimes be needed to assert to the compiler that you know something is
9494
it thinks it might be - to do that you can use the [`!` operator][null-forgiving]. You may want to
9595
do this for properties that you know are populated elsewhere; for example:
9696

97-
```c#
97+
```csharp
9898
public class User
9999
{
100100
public string Email { get; set; }

0 commit comments

Comments
 (0)