|
| 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 |
0 commit comments