DEV Community

Cover image for The Real Difference Between Add, TryAdd, and TryAddEnumerable in .NET
Girgis Adel
Girgis Adel

Posted on

The Real Difference Between Add, TryAdd, and TryAddEnumerable in .NET

A lot of developers think DI registration is simple:

services.AddScoped<IMyService, MyService>();
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now resolution depends on how you inject.


Injecting a Single Instance

public class Handler
{
    public Handler(IProcessor processor)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

You get:

ProcessorB (the last registration)
Enter fullscreen mode Exit fullscreen mode

Last registration wins.


Injecting IEnumerable

public class Handler
{
    public Handler(IEnumerable<IProcessor> processors)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

You get:

ProcessorA
ProcessorB
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

If IEmailSender is already registered (even once), this does nothing.
It checks:

Is there ANY ServiceDescriptor with this ServiceType?
Enter fullscreen mode Exit fullscreen mode

If yes → skip.


Why This Exists

Because libraries shouldn't force implementations.

Imagine you write a logging package:

services.TryAddSingleton<ILogger, ConsoleLogger>();
Enter fullscreen mode Exit fullscreen mode

Now the app developer can override it:

services.AddSingleton<ILogger, SerilogLogger>();
Enter fullscreen mode Exit fullscreen mode

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>());
Enter fullscreen mode Exit fullscreen mode

If EmailValidator is already registered for IValidator,
it will NOT be added again.

But this still works:

services.TryAddEnumerable(
    ServiceDescriptor.Scoped<IValidator, PhoneValidator>());
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

Only the first one is added.

That's not what you want when building pipelines.


Real Use Case: Handler Pipelines

Think about:

  • INotificationHandler
  • IMiddleware
  • IPipelineBehavior
  • IValidator

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>());
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

Removes all registrations for that service type.

Often used in test setups:

services.RemoveAll<IEmailSender>();
services.AddScoped<IEmailSender, FakeEmailSender>();
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

IEnumerable<IProcessor> resolves in this order:

  1. Logging
  2. Validation
  3. 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<>));
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 TryAdd silently 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.

Top comments (0)