A lot of developers think DI registration is simple:
services.AddScoped<IMyService, MyService>();
Done.
Until someone registers the same interface twice.
Or a NuGet package adds a default implementation.
Or your IEnumerable<T> suddenly contains duplicates.
Let’s walk through this properly.
1️⃣ What Add* Actually Does
When you call:
services.AddScoped<IProcessor, ProcessorA>();
services.AddScoped<IProcessor, ProcessorB>();
You are not replacing the previous registration.
You are adding another descriptor to the container.
The container internally stores something like:
IProcessor → ProcessorA
IProcessor → ProcessorB
Now resolution depends on how you inject.
Injecting a Single Instance
public class Handler
{
public Handler(IProcessor processor)
{
}
}
You get:
ProcessorB (the last registration)
Last registration wins.
Injecting IEnumerable
public class Handler
{
public Handler(IEnumerable<IProcessor> processors)
{
}
}
You get:
ProcessorA
ProcessorB
In registration order.
This behavior is fundamental.
It explains 90% of DI confusion.
2️⃣ TryAdd — Conditional Registration
TryAdd exists for one reason:
Register this service only if it hasn’t been registered before.
Example:
services.TryAddScoped<IEmailSender, DefaultEmailSender>();
If IEmailSender is already registered (even once), this does nothing.
It checks:
Is there ANY ServiceDescriptor with this ServiceType?
If yes → skip.
Why This Exists
Because libraries shouldn’t force implementations.
Imagine you write a logging package:
services.TryAddSingleton<ILogger, ConsoleLogger>();
Now the app developer can override it:
services.AddSingleton<ILogger, SerilogLogger>();
If your library used AddSingleton instead of TryAddSingleton,
you’d override the application’s choice.
That’s bad library design.
TryAdd makes your package extensible by default.
3️⃣ TryAddEnumerable — The One Most People Misunderstand
This one is different.
It doesn’t check whether the service type exists.
It checks whether the same implementation type already exists.
Example:
services.TryAddEnumerable(
ServiceDescriptor.Scoped<IValidator, EmailValidator>());
If EmailValidator is already registered for IValidator,
it will NOT be added again.
But this still works:
services.TryAddEnumerable(
ServiceDescriptor.Scoped<IValidator, PhoneValidator>());
Because it’s a different implementation.
Why Not Use TryAdd?
Because TryAdd would block ALL additional registrations.
Example:
services.TryAddScoped<IValidator, EmailValidator>();
services.TryAddScoped<IValidator, PhoneValidator>();
Only the first one is added.
That’s not what you want when building pipelines.
Real Use Case: Handler Pipelines
Think about:
INotificationHandlerIMiddlewareIPipelineBehaviorIValidator
You want multiple implementations.
But you don’t want duplicates if two modules register the same one.
That’s exactly what TryAddEnumerable solves.
4️⃣ Replace — Overwrite Intentionally
Sometimes you don’t want “last wins”.
You want explicit replacement.
services.Replace(
ServiceDescriptor.Scoped<IEmailSender, NewEmailSender>());
This:
- Removes existing registrations for
IEmailSender - Adds the new one
It’s cleaner than stacking registrations.
Useful in:
- Integration testing
- Swapping real services with mocks
- Overriding framework services
5️⃣ RemoveAll — Nuclear Option
services.RemoveAll<IEmailSender>();
Removes all registrations for that service type.
Often used in test setups:
services.RemoveAll<IEmailSender>();
services.AddScoped<IEmailSender, FakeEmailSender>();
Clean override.
6️⃣ Subtle but Important: Registration Order Matters
The container preserves registration order.
This matters when:
- Injecting
IEnumerable<T> - Building middleware pipelines
- Using decorators manually
Example:
services.AddScoped<IProcessor, LoggingProcessor>();
services.AddScoped<IProcessor, ValidationProcessor>();
services.AddScoped<IProcessor, BusinessProcessor>();
IEnumerable<IProcessor> resolves in this order:
- Logging
- Validation
- Business
If you change the order, behavior changes.
There is no sorting. No magic.
Just insertion order.
7️⃣ Edge Case: Open Generics
All these methods work with open generics too:
services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
TryAdd works the same way.
But remember:
TryAdd checks by ServiceType —
so registering different generic constraints can behave unexpectedly.
Be careful in shared libraries.
8️⃣ What Most Developers Don’t Realize
The container stores registrations as a simple list:
List<ServiceDescriptor>
Resolution rules are simple:
- Single injection → last descriptor
- IEnumerable injection → all descriptors (in order)
- TryAdd → check existence by ServiceType
- TryAddEnumerable → check existence by ServiceType + ImplementationType
There’s no complex resolution tree.
No ranking system.
No override priority.
It’s deterministic. Predictable. Simple.
And that’s why understanding it matters.
Quick Comparison Table
| Method | Allows Multiple? | Skips If Exists? | Prevents Duplicates? | Typical Use |
|---|---|---|---|---|
| Add | Yes | No | No | Normal registration |
| TryAdd | No (first wins) | Yes (by ServiceType) | Yes | Library defaults |
| TryAddEnumerable | Yes | Yes (by Impl type) | Yes (per impl) | Pipelines |
| Replace | No | Removes existing | Yes | Explicit override |
| RemoveAll | No | Clears all | Yes | Testing / resets |
Practical Rules I Follow
- Application code → use
Add - Reusable libraries → use
TryAdd - Handler collections → use
TryAddEnumerable - Tests → use
RemoveAll+Add - Intentional override → use
Replace
Keep it explicit.
Final Thought
Most DI confusion doesn’t come from lifetimes.
It comes from misunderstanding registration behavior.
When something “randomly” resolves the wrong implementation,
it’s almost always because:
- You registered twice
- Order changed
- Or
TryAddsilently skipped something
The container isn’t being clever.
It’s being literal.
And once you understand that,
you stop guessing — and start designing your registrations intentionally.
