ASP.NET Core  

Mastering Dependency Injection in ASP.NET Core – Complete Beginner to Advanced Guide

Dependency Injection (DI) is at the heart of ASP.NET Core. You see it when you create controllers, register services, build middleware, or configure logging and configuration. Almost everything in ASP.NET Core is wired together using DI.

Yet many developers still only know it as:

“We add services in builder.Services and use them in constructor.”

This article takes you beyond that.

We’ll start from the absolute basics and gradually move into real-world, interview-level, and advanced concepts—all in a clear, structured, and practical way.

1. What is Dependency Injection?

In simple terms:

Dependency Injection is a technique where a class receives the objects it needs (its dependencies) from the outside, instead of creating them itself.

A dependency is just another class or service that your class needs to do its work.

Example without DI (Tight Coupling)

public class EmailService
{
    public void Send(string to, string message)
    {
        // send email logic
    }
}

public class Notification
{
    private readonly EmailService email = new EmailService(); //  Bad

    public void Notify(string userEmail)
    {
        email.Send(userEmail, "Welcome!");
    }
}

Here, Notification:

  • Creates EmailService itself.

  • It is tightly tied to EmailService.

  • Cannot easily switch to SMS, WhatsApp, or any other notification type.

Example with DI (Loose Coupling)

public class EmailService
{
    public void Send(string to, string message)
    {
        // send email logic
    }
}

public class Notification
{
    private readonly EmailService email;

    public Notification(EmailService email) // Injected
    {
        email = email;
    }

    public void Notify(string userEmail)
    {
        email.Send(userEmail, "Welcome!");
    }
}

Now:

  • The Notification class does not create EmailService.

  • It only uses the instance that is given to it.

  • This makes the class easier to test and easier to replace with other implementations.

2. What Problem Does DI Solve?

Dependency Injection primarily solves the problem of tight coupling.

When a class creates its own dependencies:

  • It is difficult to unit test (you can’t easily mock dependencies).

  • It is difficult to swap implementations (e.g., different email providers).

  • It is difficult to extend behavior (e.g., log every notification, or send SMS instead).

Tightly coupled classes are hard to test, hard to replace, and hard to extend.

With DI, instead:

  • You depend on abstractions (interfaces), not concrete classes.

  • You delegate the creation of dependencies to a central mechanism (IoC container).

  • You can swap implementations without modifying business logic.

3. DI and the Dependency Inversion Principle (SOLID)

DI is closely connected to the Dependency Inversion Principle (DIP)—the “D” in SOLID:

High-level modules should not depend on low-level modules. Both should depend on abstractions

Example with Interface Abstraction

public interface IMessageService
{
    void Send(string msg);
}

public class EmailService : IMessageService
{
    public void Send(string msg)
    {
        // send email
    }
}

public class SmsService : IMessageService
{
    public void Send(string msg)
    {
        // send sms
    }
}

public class Notification
{
    private readonly IMessageService messageService;

    public Notification(IMessageService messageService)
    {
        messageService = messageService;
    }

    public void Notify(string message)
    {
        messageService.Send(message);
    }
}

Here:

  • Notification depends on IMessageService, not directly on EmailService or SmsService.

  • You can inject any implementation of IMessageService.

  • You follow Dependency Inversion and make your code clean and flexible.

4. Benefits of Dependency Injection

  1. Cleaner Code

    • Classes have single responsibilities.

    • No “new” scattered everywhere, creating dependencies.

  2. Easier Unit Testing

    • You can pass mocks or fakes into constructors.

    • No need to hit real databases, APIs, or external systems.

  3. Improved Maintainability

    • Changes in one service don’t force code changes everywhere.

    • Centralized configuration of dependencies.

  4. Flexibility and Extensibility

    • Swap implementations (e.g., Email provider) by changing DI registration.

  5. Encourages Good Architecture

    • Naturally pushes you toward SOLID, layered architecture, and clean code.

5. Types of Dependency Injection

Three main types of DI used in .NET:

  1. Constructor Injection (most common)

  2. Property Injection

  3. Method Injection

5.1 Constructor Injection (Preferred)

Constructor injection is where dependencies are passed via the constructor.

public class PaymentService
{
    private readonly ILogger _logger;

    public PaymentService(ILogger logger)
    {
        _logger = logger;
    }

    public void Process()
    {
        _logger.Log("Payment processed.");
    }
}

Why it’s preferred:

  • Dependencies become required: the object cannot be constructed without them.

  • Makes the class easier to reason about.

  • Great for testability.

5.2 Property Injection

Dependencies are assigned via public properties (less common in ASP.NET Core, but still useful in some scenarios).

public class ReportService
{
    public ILogger Logger { get; set; }

    public void Generate()
    {
        Logger?.Log("Report generated.");
    }
}

Drawbacks

  • Dependencies are optional.

  • Object can be in an invalid state if properties are not set.

  • Not directly supported by the built-in container (you’d need manual wiring or custom logic).

5.3 Method Injection

Dependencies are passed directly into the method that needs them.

Example using [FromServices] to inject a service directly into an action method:

public IActionResult Run([FromServices] IReportService report)
{
    return Ok(report.Generate());
}

This is useful when:

  • You do not need the service in the entire class, only in one or two methods.

  • You want clear visibility that the method depends on an external service.

6. What is an IoC Container?

An IoC (Inversion of Control) Container is the mechanism that:

  • Knows which interfaces map to which implementations.

  • Creates objects when needed.

  • Resolves and injects dependencies automatically.

The IoC container is built in, so you don’t need third-party libraries to start.

Examples of IoC containers (in general):

  • Built-in ASP.NET Core container

  • Autofac

  • StructureMap

  • Ninject

  • SimpleInjector

In ASP.NET Core, we typically use the built-in container unless we have very advanced needs.

7. Registering Services in ASP.NET Core

In ASP.NET Core, DI registration is done in Program.cs.

builder.Services.AddTransient<IMessageService, EmailService>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<ILogService, LogService>();

This tells the IoC container:

  • When someone asks for IMessageService, give an EmailService.

  • When someone asks for IOrderService, give OrderService, etc.

Services are then resolved by:

  • Constructors in controllers, services, middleware, etc.

8. Service Lifetimes: Transient, Scoped, Singleton

Service lifetime defines how long the service instance lives.

The list three lifetimes:

  1. Transient – new instance every time

  2. Scoped – one instance per HTTP request

  3. Singleton – one instance for the entire application

Let’s look at each in detail.

8.1 Transient

Definition: A new instance is created every time it’s requested.

builder.Services.AddTransient<IValidator, UserValidator>();

Use Transient for:

  • Lightweight, stateless services

  • Validation services

  • Simple helper utilities

Pros

  • No shared state issues

  • Always fresh

Cons

  • It can be expensive if the object creation is heavy and used in tight loops

8.2 Scoped

Definition: One instance is created per HTTP request.

builder.Services.AddScoped<IProductService, ProductService>();

Use Scoped for:

  • Request-specific services

  • Database contexts

  • Anything that should share state during a single HTTP request but not across requests

Pros:

  • Good balance between sharing and isolation

  • Avoids concurrency problems across requests

8.3 Singleton

Definition: One instance is created for the entire application lifetime.

builder.Services.AddSingleton<ILogService, LogService>();

Use Singleton for:

  • Caching services

  • Configuration readers

  • Lightweight, stateless services that are safe to share

Pros

  • Very efficient for repeated use

  • Good when the state must be shared globally

Cons

  • Must be thread-safe

  • Dangerous if used with objects that depend on request-specific data

9. The Captive Dependency Problem

One of the more advanced topics is the captive dependency problem.

This happens when a long-living service (like a Singleton) depends on a shorter-living service (like Scoped).

For example:

builder.Services.AddSingleton<ReportingService>();
builder.Services.AddScoped<IUnitOfWork, EfUnitOfWork>();
public class ReportingService
{
    private readonly IUnitOfWork _uow;

    public ReportingService(IUnitOfWork uow)
    {
        _uow = uow; //  Scoped into Singleton
    }
}

Here:

  • ReportingService is a Singleton.

  • It depends on a Scoped service IUnitOfWork.

  • ASP.NET Core will throw an error like:

“Cannot consume scoped service ‘X’ from singleton ‘Y’.”

This is a captive dependency and is considered a design smell.

Rule of thumb:

  • Singleton cannot depend on Scoped or Transient (unless carefully managed).

  • Scoped can depend on Transient.

  • Transient can depend on anything (but design still matters).

10. DI in Controllers (Typical Usage)

The classic controller example:

public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service) // usually Scoped
    {
        _service = service;
    }

    [HttpGet("{id}")]
    public IActionResult Get(int id)
    {
        var product = _service.GetById(id);
        return Ok(product);
    }
}

Key points

  • IProductService is typically registered as Scoped.

  • ASP.NET Core automatically injects it into the constructor.

  • You don’t call new ProductService() anywhere; DI handles it.

11. DI in Middleware

Middleware supports constructor injection, but scoped services should be injected via InvokeAsync.

Constructor Injection for Singleton-like Services

public class LoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogService _log;

    public LoggingMiddleware(RequestDelegate next, ILogService log)
    {
        _next = next;
        _log = log;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        _log.Log("Request started");
        await _next(context);
        _log.Log("Request finished");
    }
}

Here, ILogService It is likely Singleton and safe to inject in the constructor.

Method Injection for Scoped Services

For Scoped services, the suggestion is to use method parameters:

public async Task InvokeAsync(HttpContext context, IMyScopedService service)
{
    // This works - service is resolved per request
    await service.DoWorkAsync();
    await _next(context);
}

ASP.NET Core resolves the scoped service per request and passes it to InvokeAsync.

12. Method Injection in Controllers

The [FromServices] An attribute allows you to inject dependencies directly into individual action methods.

[HttpGet("report")]
public IActionResult GetReport([FromServices] IReportService reportService)
{
    var report = reportService.Generate();
    return Ok(report);
}

Use this pattern when:

  • The service is only needed for one or a few actions.

  • You don’t want it as a required constructor dependency.

13. Multiple Implementations of the Same Interface

As your application grows, you might need multiple implementations for the same interface (e.g., Email, SMS, Push notification). You can register multiple implementations and resolve them appropriately.

A common approach in ASP.NET Core is:

  • Register multiple implementations

  • Inject IEnumerable<IMessageService>

  • Or create a factory that picks one based on a key

Example: Multiple IMessageService Implementations

public class SmsService : IMessageService
{
    public void Send(string msg) { /* send SMS */ }
}

public class EmailService : IMessageService
{
    public void Send(string msg) { /* send Email */ }
}

builder.Services.AddTransient<IMessageService, SmsService>();
builder.Services.AddTransient<IMessageService, EmailService>();
public class NotificationCenter
{
    private readonly IEnumerable<IMessageService> _messageServices;

    public NotificationCenter(IEnumerable<IMessageService> messageServices)
    {
        _messageServices = messageServices;
    }

    public void SendAll(string message)
    {
        foreach (var svc in _messageServices)
        {
            svc.Send(message);
        }
    }
}

Alternatively, you can create a factory that chooses by type (e.g., "sms", "email") using a dictionary strategy.

14. Best Practices for DI in ASP.NET Core

  1. Depend on Interfaces, not Concrete Types

    • Improves testability and flexibility.

  2. Use Constructor Injection for Required Dependencies

    • Keep constructors small and meaningful.

    • If more than 5–6 dependencies, consider refactoring that class.

  3. Avoid the Service Locator Pattern

    • Don’t inject IServiceProvider and manually resolve dependencies everywhere.

    • Let DI inject what you need.

  4. Choose Correct Lifetimes

    • DbContext → Scoped

    • Loggers → Singleton

    • Validation Services → Transient

  5. Avoid Captive Dependency

    • Don’t allow Singleton to depend on Scoped.

  6. Keep Services Stateless Where Possible

    • Easier to reason about and share (Singleton/Transient).

  7. Register Services in a Single Place

    • For maintainability, use extension methods for complex modules:

      public static class ServiceCollectionExtensions
      {
          public static IServiceCollection AddBilling(this IServiceCollection services)
          {
              services.AddScoped<IBillingService, BillingService>();
              // other billing registrations
              return services;
          }
      }

15. Common Mistakes with DI

  • Putting Business Logic in Controllers instead of Services.

  • Misusing lifetimes, especially by making everything a Singleton.

  • Using new inside services, breaking DI principles.

  • Injecting too many dependencies into one class (God classes).

  • Not handling disposal of IDisposable services outside the container’s control.

16. Interview-Focused Recap

If you’re preparing for interviews, here are some typical questions from this article:

  • What is Dependency Injection, and why is it important in ASP.NET Core?

  • What problem does DI solve?

  • Explain the Dependency Inversion Principle with an example.

  • What are the different types of DI (constructor, property, method)?

  • What is an IoC container, and how does ASP.NET Core use it?

  • How do you register services with different lifetimes?

  • When do you use Transient/Scoped/Singleton?

  • What is the captive dependency problem?

  • How does DI work in middleware?

  • How do you inject services into action methods using [FromServices]?

  • How do you handle multiple implementations of the same interface?

If you can confidently answer these, you have a strong grasp of DI in ASP.NET Core.