DOTNET EXPERT BLOG

Dependency Injection in .NET: A Practical Guide for C# and ASP.NET Core Developers

10/1/2026 10:53:11 PM Noor All Safaet Loading... 0

Dependency Injection is one of the most important concepts to understand when building modern applications with .NET and ASP.NET Core.

You encounter it almost immediately:

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

Then somewhere else:

public ProductsController(IProductService productService)
{
    _productService = productService;
}

The syntax is simple.

Understanding what actually happens behind that syntax is more important.

Why should ProductService be registered?

Who creates it?

How does .NET choose a constructor?

What does Scoped actually mean?

When should you use Singleton or Transient?

What happens when a singleton depends on a scoped service?

How should DbContext be handled?

What are open generic registrations?

How do IOptions, IOptionsSnapshot, and IOptionsMonitor relate to Dependency Injection?

And why should you avoid resolving everything manually from IServiceProvider?

This guide answers those questions by building a practical mental model of Dependency Injection in .NET.


What Is Dependency Injection?

Dependency Injection, commonly called DI, is a design technique where an object receives the dependencies it needs instead of creating those dependencies itself.

Consider a product service that needs to send notifications.

Without Dependency Injection:

public class ProductService
{
    private readonly EmailService _emailService;

    public ProductService()
    {
        _emailService = new EmailService();
    }
}

ProductService creates its own EmailService.

That creates tight coupling:

ProductService
      ↓
Creates EmailService itself
      ↓
Controls dependency creation

Now imagine that later you want to:

  • Replace email with another provider

  • Mock notifications during testing

  • Change configuration

  • Add logging

  • Use different implementations

  • Manage object lifetime centrally

Direct construction becomes restrictive.

With Dependency Injection:

public class ProductService
{
    private readonly IEmailService _emailService;

    public ProductService(IEmailService emailService)
    {
        _emailService = emailService;
    }
}

Now ProductService does not decide how IEmailService is created.

It simply declares:

I need something that implements IEmailService.

The DI container handles the construction.


The Core Idea Behind Dependency Injection

Without DI:

ProductService
      ↓
new EmailService()

With DI:

DI Container
     ↓
Creates EmailService
     ↓
Injects it into ProductService

The class focuses on using its dependencies instead of constructing and managing them.

That separation is the foundation of Dependency Injection.


What Is a Dependency?

A dependency is something another class needs to perform its work.

Suppose:

public class OrderService
{
    private readonly IPaymentService _paymentService;
    private readonly IEmailService _emailService;

    public OrderService(
        IPaymentService paymentService,
        IEmailService emailService)
    {
        _paymentService = paymentService;
        _emailService = emailService;
    }
}

OrderService has two dependencies:

IPaymentService
IEmailService

The class does not need to know exactly how those objects were constructed.

It only needs objects that satisfy the required contracts.


Dependency Injection vs Dependency Inversion

These terms are related, but they are not identical.

Dependency Injection describes how dependencies are supplied to an object.

Dependency Inversion Principle is one of the SOLID principles. It encourages high-level application logic to avoid unnecessary coupling to low-level implementation details.

For example:

OrderService
     ↓
IPaymentService
     ↑
StripePaymentService

OrderService depends on:

IPaymentService

rather than being permanently tied to:

StripePaymentService

Dependency Injection is one technique that can help implement this type of design.


Dependency Injection in ASP.NET Core

ASP.NET Core includes a built-in Dependency Injection container.

A typical application begins with:

var builder = WebApplication.CreateBuilder(args);

The service collection is available through:

builder.Services

Application services can be registered there:

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

Then:

var app = builder.Build();

During application execution, .NET can resolve registered services and construct their dependency graphs.


Registration vs Resolution

Two concepts are important:

Registration
    ↓
Tell the container what services are available

Resolution
    ↓
Ask the container to build a required service

For example:

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

is registration.

Later, when the application needs:

IProductService

the DI container determines that it should create:

ProductService

That is part of dependency resolution.


The Three Main Parts of Dependency Injection

A useful way to understand DI is to divide it into three steps.

1. Define the Dependency

public interface IProductService
{
    Task<List<Product>> GetProductsAsync();
}

2. Implement the Dependency

public class ProductService : IProductService
{
    public async Task<List<Product>> GetProductsAsync()
    {
        // Load products

        return new List<Product>();
    }
}

3. Register the Dependency

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

Then request it:

public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(
        IProductService productService)
    {
        _productService = productService;
    }
}

The flow becomes:

ProductsController
       ↓ needs
IProductService
       ↓ registered as
ProductService
       ↓ created by
.NET DI Container

Constructor Injection

Constructor injection is the most common DI pattern in .NET applications.

Example:

public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IOrderRepository repository,
        ILogger<OrderService> logger)
    {
        _repository = repository;
        _logger = logger;
    }
}

The dependencies are explicit.

Anyone reading the constructor can immediately see what OrderService requires.


How Does .NET Resolve Constructor Dependencies?

Understanding this process makes DI much easier to reason about.

Suppose the container needs to create:

ProductService

and the class has:

public ProductService(
    IProductRepository repository,
    ILogger<ProductService> logger)
{
}

The container inspects the constructor and discovers two dependencies:

IProductRepository
ILogger<ProductService>

It then attempts to resolve both.

For example:

ProductService
      │
      ├── IProductRepository
      │        ↓
      │   ProductRepository
      │        ↓
      │   AppDbContext
      │
      └── ILogger<ProductService>
               ↓
          Logging Infrastructure

If a required constructor dependency cannot be resolved, service creation fails.

For example, if this registration is missing:

builder.Services.AddScoped<
    IProductRepository,
    ProductRepository>();

the container cannot successfully construct ProductService.

This is why errors such as:

Unable to resolve service for type ...

usually indicate that a required dependency has not been registered or the dependency graph cannot be constructed.


Constructor Selection Matters

Classes used with DI should have constructors that the container can resolve without ambiguity.

A simple service is easy to understand:

public class ProductService
{
    public ProductService(
        IProductRepository repository)
    {
    }
}

When multiple public constructors exist, constructor selection can become less obvious and may create ambiguity depending on which parameters the container can resolve.

For DI-managed services, prefer a clear constructor that expresses the dependencies required by the class.

The constructor should answer:

What does this class need in order to work correctly?


Why Constructor Injection Is Useful

Constructor injection provides several practical benefits.

Dependencies Are Visible

You can immediately see:

public OrderService(
    IOrderRepository repository,
    ILogger<OrderService> logger)

Required Dependencies Are Explicit

A service cannot normally be created correctly without supplying what it requires.

Testing Becomes Easier

Tests can provide alternative implementations.

Business Classes Do Not Need the DI Container

The class does not need to know how dependencies are resolved.

That keeps business logic separate from dependency-resolution infrastructure.


Service Lifetimes in .NET

The three commonly used service lifetimes are:

AddTransient
AddScoped
AddSingleton

For example:

builder.Services.AddTransient<
    IEmailFormatter,
    EmailFormatter>();

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

builder.Services.AddSingleton<
    ICacheService,
    CacheService>();

The lifetime controls how instances are created and reused.


Transient Lifetime

A transient service is created when it is requested from the container.

Register:

builder.Services.AddTransient<
    IEmailFormatter,
    EmailFormatter>();

Conceptually:

Resolve service
      ↓
Instance A

Resolve again
      ↓
Instance B

Transient services can be appropriate for lightweight, stateless operations.

Examples might include:

  • Formatters

  • Small calculation services

  • Stateless transformation services

Do not choose transient simply because the service looks small.

Its dependencies and usage pattern still matter.


Scoped Lifetime

A scoped service is created once within a Dependency Injection scope.

In a typical ASP.NET Core HTTP application, one request normally corresponds to one request scope.

Register:

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

Conceptually:

HTTP Request A
      ↓
ProductService A
      ↓
Reused inside Request A

HTTP Request B
      ↓
ProductService B

Scoped services are common for request-oriented application operations.

Entity Framework Core DbContext is an important example of a dependency commonly configured with a scoped lifetime in traditional ASP.NET Core request processing.


Singleton Lifetime

A singleton service is shared from the application's root service provider.

Register:

builder.Services.AddSingleton<
    ICacheService,
    CacheService>();

Conceptually:

Application
     ↓
Singleton Instance
     ↓
Request A
Request B
Request C

Because the service can be accessed across multiple requests and threads, shared mutable state requires particular care.

A singleton should be designed for its long lifetime.


Transient vs Scoped vs Singleton

LifetimeInstance BehaviorCommon Consideration
TransientCreated when resolvedLightweight/stateless operations
ScopedShared within one DI scopeRequest-oriented services
SingletonShared from root containerLong-lived/shared infrastructure

A simple mental model:

Transient
→ Short-lived

Scoped
→ Scope-lived

Singleton
→ Application-lived

But the table is not a rule for choosing lifetimes.

The correct lifetime depends on the responsibility and dependency graph of the service.


How Should You Choose a Service Lifetime?

Instead of memorizing:

Repository = Scoped
Utility = Transient
Cache = Singleton

ask practical questions.

Does the service contain mutable state?

If yes, determine who should own that state and how long it should exist.

Does it depend on DbContext?

That often influences the lifetime because a normal DbContext is commonly scoped.

Will multiple requests access the same instance?

If yes, thread safety and shared state matter.

Does it represent one unit of work?

A scoped lifetime may be appropriate.

Is it lightweight and stateless?

Transient may be reasonable.

Does it represent shared application infrastructure?

Singleton may be appropriate if it is designed for concurrent, long-lived use.

The key principle is:

Choose the lifetime from the service's behavior and dependencies, not from its class name.


Why Service Lifetime Matters

Consider:

public class ShoppingCartService
{
    public List<int> ProductIds { get; } = new();
}

If registered as:

builder.Services.AddSingleton<ShoppingCartService>();

the same mutable instance may be shared across users.

That can produce serious bugs.

Changing it to:

builder.Services.AddScoped<ShoppingCartService>();

can isolate the service by request scope.

But that still does not automatically make it a proper persistent shopping cart.

A real cart might need:

Database
Distributed Cache
Session
Persistent User Storage

DI lifetime and business-data lifetime are different concepts.


The Captive Dependency Problem

One of the most important DI lifetime problems occurs when a long-lived service captures a shorter-lived dependency.

Suppose:

builder.Services.AddScoped<
    IOrderRepository,
    OrderRepository>();

builder.Services.AddSingleton<
    IReportService,
    ReportService>();

Then:

public class ReportService : IReportService
{
    private readonly IOrderRepository _repository;

    public ReportService(
        IOrderRepository repository)
    {
        _repository = repository;
    }
}

The dependency graph becomes:

Singleton ReportService
          ↓
Scoped OrderRepository

The singleton can effectively retain something that was intended to live only within a scope.

This is commonly called a captive dependency.

Scope validation can detect many lifetime mismatches during development.

The general principle is:

A long-lived service should not directly capture a shorter-lived scoped dependency.


Scoped Services Inside Background Services

Background services are a common place where developers encounter this issue.

Hosted services are typically singleton services.

Suppose a worker needs a scoped IOrderService.

Create a scope for each appropriate unit of work:

public class OrderProcessingWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public OrderProcessingWorker(
        IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope =
                _scopeFactory.CreateScope();

            var orderService =
                scope.ServiceProvider
                    .GetRequiredService<IOrderService>();

            await orderService
                .ProcessPendingOrdersAsync(
                    stoppingToken);

            await Task.Delay(
                TimeSpan.FromMinutes(1),
                stoppingToken);
        }
    }
}

The architecture is:

Singleton BackgroundService
          ↓
Create Scope
          ↓
Resolve Scoped Service
          ↓
Perform Work
          ↓
Dispose Scope

Here, using IServiceProvider inside the intentionally created scope is infrastructure behavior rather than hidden business-service dependency resolution.


Dependency Injection and Entity Framework Core

Entity Framework Core integrates naturally with .NET DI.

For example:

builder.Services.AddDbContext<AppDbContext>(
    options =>
    {
        options.UseSqlServer(
            builder.Configuration
                .GetConnectionString(
                    "DefaultConnection"));
    });

Then:

public class ProductRepository
{
    private readonly AppDbContext _dbContext;

    public ProductRepository(
        AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }
}

The repository does not create the context itself.

The application configures the dependency centrally.


DbContext Lifetime Requires Special Attention

DbContext represents a unit of work and should not be treated as an indefinitely shared, thread-safe database object.

This becomes especially important in:

  • Background processing

  • Parallel operations

  • Long-running workflows

  • Interactive server-side UI

  • Services that live longer than an HTTP request

For scenarios that need independently created contexts, IDbContextFactory<TContext> can be useful.

Register:

builder.Services.AddDbContextFactory<AppDbContext>(
    options =>
    {
        options.UseSqlServer(
            builder.Configuration
                .GetConnectionString(
                    "DefaultConnection"));
    });

Inject:

public class ProductService
{
    private readonly IDbContextFactory<AppDbContext>
        _dbContextFactory;

    public ProductService(
        IDbContextFactory<AppDbContext>
            dbContextFactory)
    {
        _dbContextFactory = dbContextFactory;
    }

    public async Task<List<Product>>
        GetProductsAsync()
    {
        await using var dbContext =
            await _dbContextFactory
                .CreateDbContextAsync();

        return await dbContext.Products
            .AsNoTracking()
            .ToListAsync();
    }
}

Now each operation can create and dispose its own context.


Interface Registration

A common registration is:

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

This means:

Request IProductService
        ↓
Create/return ProductService

Then:

public ProductsController(
    IProductService productService)
{
}

The controller depends on the abstraction rather than a particular implementation.


Do You Always Need an Interface?

No.

A concrete service can be registered directly:

builder.Services.AddScoped<ProductService>();

Then injected:

public ProductsController(
    ProductService productService)
{
}

Interfaces become particularly useful when:

  • Multiple implementations exist

  • Implementations may change

  • Infrastructure should be isolated

  • The abstraction represents a meaningful capability

  • Tests benefit from substitution

Creating an interface for every class does not automatically produce better architecture.

Use abstractions intentionally.


Open Generic Dependency Injection

Generics become especially useful when the same abstraction applies to many types.

Consider:

public interface IRepository<T>
    where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<List<T>> GetAllAsync();
}

Implementation:

public class Repository<T> : IRepository<T>
    where T : class
{
    private readonly AppDbContext _dbContext;

    public Repository(
        AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<T?> GetByIdAsync(int id)
    {
        return await _dbContext
            .Set<T>()
            .FindAsync(id);
    }

    public async Task<List<T>> GetAllAsync()
    {
        return await _dbContext
            .Set<T>()
            .AsNoTracking()
            .ToListAsync();
    }
}

Instead of registering every closed type separately:

builder.Services.AddScoped<
    IRepository<Product>,
    Repository<Product>>();

builder.Services.AddScoped<
    IRepository<Customer>,
    Repository<Customer>>();

you can register the open generic types:

builder.Services.AddScoped(
    typeof(IRepository<>),
    typeof(Repository<>));

Now the container can resolve:

IRepository<Product>
IRepository<Customer>
IRepository<Order>

using the same generic implementation.

Conceptually:

IRepository<Product>
        ↓
Repository<Product>

IRepository<Customer>
        ↓
Repository<Customer>

Open generic registration is powerful when a genuinely reusable generic abstraction exists.

Do not create generic repositories merely because DI supports them.

The abstraction should still provide architectural value.


Multiple Implementations of the Same Interface

Suppose:

public interface INotificationService
{
    Task SendAsync(string message);
}

Two implementations:

public class EmailNotificationService
    : INotificationService
{
    public Task SendAsync(string message)
    {
        return Task.CompletedTask;
    }
}

and:

public class SmsNotificationService
    : INotificationService
{
    public Task SendAsync(string message)
    {
        return Task.CompletedTask;
    }
}

Register:

builder.Services.AddScoped<
    INotificationService,
    EmailNotificationService>();

builder.Services.AddScoped<
    INotificationService,
    SmsNotificationService>();

When you need all implementations:

public class NotificationManager
{
    private readonly IEnumerable<INotificationService>
        _services;

    public NotificationManager(
        IEnumerable<INotificationService> services)
    {
        _services = services;
    }

    public async Task NotifyAsync(
        string message)
    {
        foreach (var service in _services)
        {
            await service.SendAsync(message);
        }
    }
}

This is useful when every registered implementation should participate.


Keyed Services

Sometimes several implementations exist, but only one should be selected for a particular operation.

For example:

builder.Services.AddKeyedScoped<
    INotificationService,
    EmailNotificationService>("email");

builder.Services.AddKeyedScoped<
    INotificationService,
    SmsNotificationService>("sms");

Then:

public class OrderNotificationService
{
    private readonly INotificationService
        _notificationService;

    public OrderNotificationService(
        [FromKeyedServices("email")]
        INotificationService notificationService)
    {
        _notificationService =
            notificationService;
    }
}

Keys can represent explicit strategies such as:

email
sms
stripe
paypal
primary
secondary

When selection becomes complex business logic, however, a strategy or factory abstraction may communicate the intent more clearly.


Dependency Injection and the Options Pattern

Configuration is another area closely connected to DI.

Suppose:

{
  "Email": {
    "Host": "smtp.example.com",
    "Port": 587
  }
}

Create an options class:

public class EmailOptions
{
    public string Host { get; set; }
        = string.Empty;

    public int Port { get; set; }
}

Register it:

builder.Services.Configure<EmailOptions>(
    builder.Configuration
        .GetSection("Email"));

.NET provides several ways to consume these options.


IOptions<T>

Inject:

public class EmailService
{
    private readonly EmailOptions _options;

    public EmailService(
        IOptions<EmailOptions> options)
    {
        _options = options.Value;
    }
}

IOptions<T> provides straightforward access to configured options.

It is useful when configuration does not need to be refreshed for each request or dynamically observed by the consuming service.


IOptionsSnapshot<T>

Example:

public class ProductService
{
    private readonly EmailOptions _options;

    public ProductService(
        IOptionsSnapshot<EmailOptions> options)
    {
        _options = options.Value;
    }
}

IOptionsSnapshot<T> is scoped.

It is designed to provide options within a scope and can reflect updated configuration on subsequent requests when the underlying configuration provider supports reload.

Because it is scoped, it should not be injected directly into a singleton.


IOptionsMonitor<T>

For long-lived services that need access to current option values, IOptionsMonitor<T> is often the relevant abstraction.

Example:

public class NotificationService
{
    private readonly IOptionsMonitor<EmailOptions>
        _options;

    public NotificationService(
        IOptionsMonitor<EmailOptions> options)
    {
        _options = options;
    }

    public void Send()
    {
        var host = _options.CurrentValue.Host;
    }
}

It can also observe option changes:

_options.OnChange(options =>
{
    // React to updated configuration
});

A useful mental model is:

Option TypeDI CharacteristicTypical Use
IOptions<T>Singleton-friendly accessBasic configuration
IOptionsSnapshot<T>ScopedPer-scope refreshed options
IOptionsMonitor<T>Singleton-friendly monitoringCurrent values / change notifications

The correct choice depends on whether configuration needs to change while the application is running and on the lifetime of the consuming service.


Dependency Injection with Logging

Logging is integrated with DI.

Inject:

public class ProductService
{
    private readonly ILogger<ProductService>
        _logger;

    public ProductService(
        ILogger<ProductService> logger)
    {
        _logger = logger;
    }

    public void ProcessProduct()
    {
        _logger.LogInformation(
            "Processing product");
    }
}

The framework supplies the logger.

Your business service does not need to construct logging infrastructure.


Dependency Injection with HttpClient

External APIs are another common dependency.

Instead of manually creating and configuring HTTP clients throughout application code, .NET provides IHttpClientFactory.

For example:

builder.Services.AddHttpClient<
    PaymentApiClient>(client =>
{
    client.BaseAddress =
        new Uri("https://api.example.com/");
});

Then:

public class PaymentApiClient
{
    private readonly HttpClient _httpClient;

    public PaymentApiClient(
        HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<string>
        GetStatusAsync()
    {
        return await _httpClient
            .GetStringAsync("status");
    }
}

This keeps HTTP configuration centralized and allows .NET to manage the underlying HTTP infrastructure appropriately.


Extension Methods Keep Program.cs Organized

As applications grow, service registration can make Program.cs difficult to read.

Instead of placing every registration there:

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

builder.Services.AddScoped<
    IOrderService,
    OrderService>();

builder.Services.AddScoped<
    ICustomerService,
    CustomerService>();

create an extension:

public static class DependencyInjection
{
    public static IServiceCollection
        AddApplicationServices(
            this IServiceCollection services)
    {
        services.AddScoped<
            IProductService,
            ProductService>();

        services.AddScoped<
            IOrderService,
            OrderService>();

        services.AddScoped<
            ICustomerService,
            CustomerService>();

        return services;
    }
}

Then:

builder.Services.AddApplicationServices();

Larger solutions can organize registration by responsibility:

AddApplicationServices()
AddInfrastructureServices()
AddPersistenceServices()

This keeps the composition root understandable without hiding where dependencies are configured.


Dependency Injection in Clean Architecture

Dependency Injection becomes particularly useful when application responsibilities are separated.

A simplified structure might look like:

Web
 ↓
Application
 ↓
Domain

Infrastructure
 ↓
implements external concerns

Suppose Application defines:

public interface IEmailSender
{
    Task SendAsync(
        string recipient,
        string message);
}

Infrastructure provides:

public class SmtpEmailSender
    : IEmailSender
{
    public Task SendAsync(
        string recipient,
        string message)
    {
        // SMTP implementation

        return Task.CompletedTask;
    }
}

Registration connects them:

builder.Services.AddScoped<
    IEmailSender,
    SmtpEmailSender>();

Conceptually:

Application Logic
       ↓
IEmailSender
       ↑
SmtpEmailSender
       ↑
Infrastructure

The application can express the capability it needs without containing SMTP implementation details.


Dependency Injection and Unit Testing

One major advantage of explicit dependencies is easier testing.

Suppose:

public class OrderService
{
    private readonly IPaymentService
        _paymentService;

    public OrderService(
        IPaymentService paymentService)
    {
        _paymentService = paymentService;
    }

    public async Task<bool>
        CheckoutAsync(decimal amount)
    {
        return await _paymentService
            .ChargeAsync(amount);
    }
}

Production may use a real payment provider.

A unit test can provide a fake:

public class FakePaymentService
    : IPaymentService
{
    public Task<bool> ChargeAsync(
        decimal amount)
    {
        return Task.FromResult(true);
    }
}

Test:

var paymentService =
    new FakePaymentService();

var orderService =
    new OrderService(paymentService);

var result =
    await orderService.CheckoutAsync(100);

Assert.True(result);

Notice that the unit test does not require the DI container.

It simply creates the class with the dependency it needs.

DI-friendly design and container-dependent design are not the same thing.

Ideally, your business classes remain easy to construct outside the container.


What Is the Service Locator Anti-Pattern?

Consider:

public class ProductService
{
    private readonly IServiceProvider
        _serviceProvider;

    public ProductService(
        IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void Process()
    {
        var emailService =
            _serviceProvider
                .GetRequiredService<IEmailService>();
    }
}

The real dependency is hidden.

From the constructor, you cannot easily tell that ProductService requires IEmailService.

Compare:

public ProductService(
    IEmailService emailService)
{
    _emailService = emailService;
}

The dependency is now explicit.

Resolving services manually is appropriate in some infrastructure scenarios, such as deliberately creating a scope in a background service.

Using IServiceProvider throughout normal business code, however, often turns DI into the Service Locator pattern.


Avoid Static Service Access

Another problematic approach is exposing the service provider globally:

public static IServiceProvider Services
{
    get;
    set;
}

Then:

var service =
    Global.Services
        .GetRequiredService<IProductService>();

This creates hidden global dependencies.

It can make:

  • Testing harder

  • Lifetime management harder

  • Dependencies invisible

  • Code more tightly coupled to infrastructure

Prefer explicit dependencies.


Too Many Constructor Dependencies Are Useful Feedback

Suppose:

public OrderService(
    IOrderRepository orders,
    ICustomerRepository customers,
    IProductRepository products,
    IPaymentService payments,
    IEmailService emails,
    IInventoryService inventory,
    IDiscountService discounts,
    IShippingService shipping,
    ILogger<OrderService> logger)
{
}

Dependency Injection did not create this complexity.

It exposed it.

A large dependency list may indicate that the class has too many responsibilities.

Instead of hiding those dependencies, reconsider the design.

For example:

Order Checkout
     ↓
OrderCheckoutService
PaymentProcessor
InventoryReservationService
OrderNotificationService

Constructor size is not a perfect metric, but it can provide useful architectural feedback.


Circular Dependencies

Consider:

ProductService
      ↓
InventoryService
      ↓
ProductService

For example:

public ProductService(
    IInventoryService inventoryService)
{
}

while:

public InventoryService(
    IProductService productService)
{
}

The dependency graph loops back on itself.

This commonly indicates a design problem.

Possible causes include:

  • Responsibilities are mixed

  • Services know too much about each other

  • Shared behavior belongs somewhere else

  • Application boundaries are unclear

Rather than forcing the container to resolve the cycle, reconsider the dependency direction.


Dependency Injection Does Not Automatically Create Good Architecture

You can use DI everywhere and still build a poorly structured application.

For example:

Controller
    ↓
HugeService
    ↓
15 Dependencies
    ↓
Everything Else

Technically, DI is being used.

Architecturally, the application may still have poor separation of concerns.

Dependency Injection manages dependencies.

It does not decide what your application architecture should be.


Common Dependency Injection Mistakes in .NET

1. Choosing Lifetimes Randomly

Do not use Scoped everywhere simply because it works.

Understand how the service is used.

2. Injecting Scoped Services into Singletons

This creates a lifetime mismatch and can produce captive dependencies.

3. Keeping DbContext Alive Too Long

A long-lived context can create tracking, concurrency, memory, and stale-data problems.

4. Using IServiceProvider Everywhere

Prefer constructor injection for ordinary application dependencies.

5. Creating Interfaces Without a Reason

Interfaces are useful abstractions, not mandatory decorations.

6. Making Everything Singleton

Singletons introduce shared-state and concurrency considerations.

7. Constructing Infrastructure Dependencies Manually

Avoid scattering:

new EmailService()
new PaymentClient()
new ProductRepository()

through business code when those dependencies should be centrally configured.

8. Hiding Circular Dependencies

Circular dependencies usually deserve an architectural fix.

9. Ignoring Large Constructors

Too many dependencies can indicate that a class has accumulated too many responsibilities.

10. Confusing DI Lifetime with Business Data Lifetime

A service lifetime describes dependency instance management.

It does not automatically define how long business data should exist.

11. Using IOptionsSnapshot Inside a Singleton

IOptionsSnapshot<T> is scoped and should not be directly captured by singleton services.

Consider IOptions<T> or IOptionsMonitor<T> according to the configuration requirements.

12. Using Open Generics Without a Real Abstraction

Generic registration is powerful, but it should solve an actual design problem rather than introduce unnecessary abstraction.


A Practical Dependency Injection Structure

A typical application might contain:

API
│
├── Controllers
│
Application
│
├── Services
├── Interfaces
│
Domain
│
├── Entities
│
Infrastructure
│
├── Persistence
├── Repositories
├── Email
├── Payments
└── External APIs

Dependencies might flow like:

Controller
    ↓
Application Service
    ↓
Abstraction
    ↓
Infrastructure Implementation
    ↓
Database / External System

Service registrations connect these pieces at application startup.


Example: Product Application

Imagine:

ProductsController
      ↓
IProductService
      ↓
ProductService
      ↓
IProductRepository
      ↓
ProductRepository
      ↓
AppDbContext
      ↓
Database

Registrations:

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

builder.Services.AddScoped<
    IProductRepository,
    ProductRepository>();

Controller:

public class ProductsController
    : ControllerBase
{
    private readonly IProductService
        _productService;

    public ProductsController(
        IProductService productService)
    {
        _productService = productService;
    }
}

Application service:

public class ProductService
    : IProductService
{
    private readonly IProductRepository
        _repository;

    public ProductService(
        IProductRepository repository)
    {
        _repository = repository;
    }
}

Repository:

public class ProductRepository
    : IProductRepository
{
    private readonly AppDbContext
        _dbContext;

    public ProductRepository(
        AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }
}

The DI container constructs the graph:

ProductsController
       ↓
ProductService
       ↓
ProductRepository
       ↓
AppDbContext

Each class declares what it needs.

The composition root determines how those dependencies are supplied.


How .NET Resolves the Full Dependency Graph

Suppose ASP.NET Core needs to create:

ProductsController

It discovers:

ProductsController
      ↓ needs
IProductService

The registration says:

IProductService
      ↓
ProductService

The constructor of ProductService requires:

IProductRepository

The container finds:

IProductRepository
      ↓
ProductRepository

ProductRepository requires:

AppDbContext

EF Core registration provides it.

The final graph becomes:

ProductsController
        ↓
IProductService
        ↓
ProductService
        ↓
IProductRepository
        ↓
ProductRepository
        ↓
AppDbContext

This recursive construction is the central idea behind DI resolution.

Once you understand the graph, many DI errors become much easier to diagnose.


A Practical Checklist for Choosing DI Lifetimes

Before registering a custom service, ask:

Does it hold mutable state?
        ↓
Who should share that state?

Does it depend on a scoped service?
        ↓
Avoid making it longer-lived accidentally.

Does it perform one request/unit of work?
        ↓
Scoped may fit.

Is it stateless and lightweight?
        ↓
Transient may fit.

Must one shared instance exist?
        ↓
Consider Singleton carefully.

Does it perform parallel operations?
        ↓
Check thread safety and dependency safety.

This is more reliable than choosing lifetimes from class names.


Dependency Injection Best Practices

A practical set of principles is:

  • Prefer explicit constructor injection

  • Keep constructors clear and unambiguous

  • Select service lifetimes intentionally

  • Keep dependency graphs understandable

  • Avoid long-lived services capturing scoped dependencies

  • Treat DbContext lifetime as a unit-of-work concern

  • Consider IDbContextFactory<TContext> when independent contexts are required

  • Use abstractions where they provide real architectural value

  • Use open generic registrations only when the generic abstraction is meaningful

  • Choose IOptions<T>, IOptionsSnapshot<T>, or IOptionsMonitor<T> according to lifetime and reload requirements

  • Avoid using IServiceProvider as a general-purpose service locator

  • Use IHttpClientFactory patterns for managed HTTP clients

  • Create explicit scopes in background infrastructure when required

  • Treat oversized constructors as possible design feedback

  • Organize registrations as the application grows

  • Keep business classes easy to construct in tests

Most importantly:

Dependency Injection should make dependencies clearer, not hide them.


Frequently Asked Questions

What Is Dependency Injection in .NET?

Dependency Injection is a technique where a class receives the dependencies it requires rather than creating those dependencies itself.

.NET includes a built-in DI container for registering and resolving services.

Does ASP.NET Core Have Built-In Dependency Injection?

Yes.

ASP.NET Core uses Dependency Injection throughout the framework.

Services are commonly registered through:

builder.Services

What Is AddTransient?

AddTransient registers a transient service.

An instance is created when the service is resolved.

What Is AddScoped?

AddScoped registers a service for a DI scope.

In typical ASP.NET Core HTTP request processing, a scoped service is normally reused within the same request scope.

What Is AddSingleton?

AddSingleton registers a service with a lifetime associated with the root service provider.

The instance can be shared across requests, so concurrency and shared mutable state require careful consideration.

How Does .NET Know Which Implementation to Inject?

You register the relationship:

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

When IProductService is requested, the container can resolve ProductService and recursively resolve its constructor dependencies.

What Does “Unable to Resolve Service” Mean?

It usually means the DI container cannot construct part of the requested dependency graph.

A required service may be missing from registration, or the dependency structure may be invalid.

What Is an Open Generic Registration?

An open generic registration connects generic service definitions.

For example:

builder.Services.AddScoped(
    typeof(IRepository<>),
    typeof(Repository<>));

This allows the container to construct closed versions such as:

IRepository<Product>
IRepository<Customer>

What Is a Captive Dependency?

A captive dependency occurs when a longer-lived service captures a shorter-lived dependency.

A common example is a singleton directly depending on a scoped service.

Which Lifetime Should I Use for DbContext?

AddDbContext commonly configures DbContext as scoped.

However, the appropriate pattern depends on the application.

Background work, long-running operations, parallel processing, and interactive server-side UI can require different context-management strategies such as IDbContextFactory<TContext>.

What Is the Difference Between IOptions, IOptionsSnapshot, and IOptionsMonitor?

IOptions<T> provides basic options access.

IOptionsSnapshot<T> is scoped and is useful when updated configuration should be available in later scopes.

IOptionsMonitor<T> can provide current option values and change notifications and can be used by singleton services.

Should Every Service Have an Interface?

No.

Use an interface when it represents a useful abstraction or substitution point.

Can a Singleton Use a Scoped Service?

A singleton should not directly capture a scoped service.

If singleton infrastructure legitimately needs scoped work, create an explicit scope for that operation.

Why Is Dependency Injection Useful for Testing?

Explicit dependencies can be replaced with fakes, mocks, or other test implementations.

That allows business logic to be tested without requiring real external infrastructure.

What Is IServiceProvider?

IServiceProvider is used to resolve services from the DI container.

It is useful for specific infrastructure scenarios, but ordinary application classes should generally request their dependencies explicitly.

What Are Keyed Services?

Keyed services allow several implementations of the same service type to be registered under different keys.

This is useful when the application needs explicit implementation selection.


Final Thoughts

Dependency Injection in .NET is not primarily about memorizing:

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

The registration syntax is the easy part.

The important part is understanding the dependency graph:

Controller
    ↓
Application Service
    ↓
Repository / Infrastructure Abstraction
    ↓
Infrastructure
    ↓
Database or External Service

Then understanding how the container manages that graph:

Registration
     ↓
Constructor Resolution
     ↓
Dependency Graph
     ↓
Service Lifetime
     ↓
Scope
     ↓
Disposal

Once those concepts are clear, many ASP.NET Core patterns become easier to understand.

Start with constructor injection.

Learn the difference between transient, scoped, and singleton lifetimes.

Understand captive dependencies.

Pay special attention to DbContext and background services.

Learn when open generic registrations are useful.

Understand how configuration options interact with DI lifetimes.

Use abstractions when they provide real value.

Avoid hiding dependencies behind global service access or service locators.

And remember:

Dependency Injection is not your application architecture.

It is infrastructure that helps you build software where dependencies are explicit, manageable, replaceable, testable, and easier to maintain.

Comments 0