DEV Community

Jairo Blanco
Jairo Blanco

Posted on

Mastering Dependency Injection in .NET

Introduction

Dependency Injection (DI) is one of those concepts that feels optional—right up until your codebase grows large enough that it becomes essential. In modern .NET applications, DI is not just a pattern; it's baked into the framework and expected as part of idiomatic design.

This article walks through how DI works in .NET, the patterns you should use, and the mistakes worth avoiding.


What Is Dependency Injection?

At its core, Dependency Injection is about inverting control. Instead of a class creating its own dependencies, those dependencies are provided externally.

Without DI

public class OrderService
{
    private readonly PaymentService _paymentService = new PaymentService();
}
Enter fullscreen mode Exit fullscreen mode

With DI

public class OrderService
{
    private readonly IPaymentService _paymentService;

    public OrderService(IPaymentService paymentService)
    {
        _paymentService = paymentService;
    }
}
Enter fullscreen mode Exit fullscreen mode

This shift enables:

  • Easier testing
  • Better separation of concerns
  • More flexible architectures

Built-in DI in .NET

.NET provides a lightweight DI container via Microsoft.Extensions.DependencyInjection.

Registration

services.AddTransient<IMyService, MyService>();
services.AddScoped<IRepository, Repository>();
services.AddSingleton<ICache, MemoryCache>();
Enter fullscreen mode Exit fullscreen mode

Resolution

In ASP.NET Core, services are automatically injected into constructors:

public class HomeController : Controller
{
    private readonly IMyService _service;

    public HomeController(IMyService service)
    {
        _service = service;
    }
}
Enter fullscreen mode Exit fullscreen mode

Service Lifetimes Explained

Choosing the correct lifetime is critical.

Transient

  • New instance every time
  • Good for lightweight, stateless services

Scoped

  • One instance per request
  • Ideal for database contexts

Singleton

  • Single instance for entire app lifetime
  • Use carefully; must be thread-safe

Common DI Patterns

1. Constructor Injection (Preferred)

The default and most recommended approach.

public class UserService
{
    private readonly IRepository _repo;

    public UserService(IRepository repo)
    {
        _repo = repo;
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Factory Pattern

Useful when creation logic is dynamic.

services.AddTransient<Func<string, IMessageService>>(provider => key =>
{
    return key switch
    {
        "email" => provider.GetRequiredService<EmailService>(),
        "sms" => provider.GetRequiredService<SmsService>(),
        _ => throw new ArgumentException()
    };
});
Enter fullscreen mode Exit fullscreen mode

3. Options Pattern

For configuration binding.

services.Configure<MyOptions>(configuration.GetSection("MyOptions"));
Enter fullscreen mode Exit fullscreen mode

Injected as:

public class MyService
{
    public MyService(IOptions<MyOptions> options)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Decorator Pattern

Wrap behavior without modifying the original implementation.

public class LoggingServiceDecorator : IService
{
    private readonly IService _inner;

    public LoggingServiceDecorator(IService inner)
    {
        _inner = inner;
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Depend on Abstractions

Always inject interfaces, not concrete implementations.

2. Keep Constructors Lean

Too many dependencies often signal a violation of the Single Responsibility Principle.

3. Avoid Service Locator Anti-Pattern

// Avoid this
var service = provider.GetService<IMyService>();
Enter fullscreen mode Exit fullscreen mode

This hides dependencies and makes testing harder.

4. Validate Scopes

Avoid injecting scoped services into singletons.

5. Use Composition Root

Keep service registrations centralized (e.g., in Program.cs).


Advanced Techniques

Conditional Registration

if (env.IsDevelopment())
{
    services.AddSingleton<IEmailService, DebugEmailService>();
}
else
{
    services.AddSingleton<IEmailService, SmtpEmailService>();
}
Enter fullscreen mode Exit fullscreen mode

Open Generics

services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
Enter fullscreen mode Exit fullscreen mode

Assembly Scanning (Scrutor)

services.Scan(scan => scan
    .FromAssembliesOf(typeof(IMarker))
    .AddClasses()
    .AsImplementedInterfaces()
    .WithScopedLifetime());
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls

  • Overusing singletons
  • Hidden circular dependencies
  • Over-injection (too many services per class)
  • Misusing DI for simple object creation

When NOT to Use DI

DI is powerful, but not always necessary. Avoid it for:

  • Simple, short-lived objects
  • Static utility functions
  • Performance-critical paths where container resolution overhead matters

Conclusion

Dependency Injection is foundational to modern .NET development. Used correctly, it enables modular, testable, and maintainable systems. Misused, it can introduce unnecessary complexity.

Focus on clarity, simplicity, and correct lifetimes—and your DI setup will scale with your application.


Further Reading

  • Official Microsoft Docs on Dependency Injection
  • Clean Architecture by Robert C. Martin
  • Patterns of Enterprise Application Architecture by Martin Fowler

Top comments (0)