What Dependency Injection Actually Solves
Dependency Injection, usually shortened to DI, sounds more complicated than it really is. At its core, DI is simply a way to give a class the objects it needs instead of forcing that class to create those objects by itself. That small change has a big effect on application design. When a controller creates its own repository, email sender, logger, or payment client, the controller becomes tied to those exact implementations. Changing or testing one part often means touching another part. With DI, the class declares what it needs, and ASP.NET Core provides the matching object at runtime. ASP.NET Core includes a built-in dependency injection container, so most applications do not need a third-party container just to use DI. Services are registered during application startup and then requested through constructors or other supported injection points. The practical goal is not to use DI everywhere because it is fashionable. The goal is to reduce tight coupling, keep responsibilities clear, make testing easier, and allow implementations to change without rewriting the code that uses them.
Simple definition: Dependency Injection means a class receives its dependencies from outside instead of creating them internally.
Understand a Dependency Before Understanding DI
A dependency is any object that another class needs in order to do its work. If an order service needs a repository to read orders from SQL Server, that repository is a dependency. If it also needs an email sender, the email sender is another dependency. The important point is that using another class is not the problem. The problem appears when the class also takes responsibility for constructing and configuring that dependency.
public class OrderService
{
private readonly OrderRepository _repository;
public OrderService()
{
_repository = new OrderRepository();
}
}
This code works, but OrderService now knows that the concrete class is OrderRepository, knows how it is created, and cannot easily use another implementation. If the repository later needs a connection factory, configuration object, cache, or logger, the service may start creating those too. That is how one simple constructor slowly becomes a web of tightly coupled objects.
Creating small value objects directly is normal. DI is mainly useful for services and infrastructure dependencies whose lifetime, configuration, implementation, or test replacement should be managed outside the consuming class.
What Changes When We Inject the Dependency?
With DI, OrderService does not create the repository. It asks for a repository through its constructor. The service can focus on order rules while the application startup code decides which repository implementation should be supplied.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken cancellationToken);
}
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
}
Now the dependency is visible. Anyone reading the constructor can immediately see what OrderService requires. The service is no longer responsible for selecting or constructing a repository. During normal execution ASP.NET Core can inject the real repository, while a unit test can supply a fake repository. This is the main value of DI: object creation moves toward the application boundary, while business classes stay focused on business behavior.
Use Constructor Injection as the Default
Constructor injection is the most common and usually the clearest form of DI in ASP.NET Core. Required dependencies are listed as constructor parameters and stored in fields. If an object cannot do its job without a dependency, putting that dependency in the constructor makes the requirement explicit.
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id,
CancellationToken cancellationToken)
{
var product = await _productService.GetByIdAsync(id, cancellationToken);
return product == null ? NotFound() : Ok(product);
}
}
The controller does not call new ProductService(). It only says: I need something that behaves like IProductService. ASP.NET Core checks the service container, finds the registered implementation, creates it together with its own dependencies, and passes it to the controller. Constructor injection also keeps objects valid. If a required dependency is missing from registration, the application fails clearly instead of creating a half-configured service that later throws a null- reference exception.
Rule of thumb: If a dependency is required for the class to work, prefer constructor injection.
Register Services in Program.cs
ASP.NET Core resolves only services that the container knows about. In a typical application, registrations are added to builder.Services in Program.cs before the app is built.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
var app = builder.Build();
app.MapControllers();
app.Run();
The first generic type is the service contract that consumers request. The second type is the implementation ASP.NET Core creates. So when a controller asks for IProductService, the container supplies ProductService. You can also register a concrete class directly when an interface would add no real value.
builder.Services.AddScoped<PricingService>();
An interface is useful when there are multiple implementations, when the dependency crosses an architectural boundary, or when tests need an easy substitute. Creating an interface for every tiny class can add noise without improving the design.
Transient, Scoped, and Singleton Explained Simply
A service registration also tells the container how long an instance should live. ASP.NET Core supports three main lifetimes: transient, scoped, and singleton. Choosing the wrong lifetime is one of the most common DI mistakes, so this part is worth understanding well.
builder.Services.AddTransient<IClockFormatter, ClockFormatter>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IAppCache, AppCache>();
For ASP.NET Core web applications, scoped is a common choice for business services and repositories because one request gets one scope. A new request receives a new scoped instance. Entity Framework Core DbContext is scoped by default when registered with AddDbContext. Singleton services live much longer. Because the same instance can be used by many requests at the same time, singleton implementations must be safe for concurrent use and should not keep request- specific state.
Important: A singleton should not directly depend on a scoped service. That lifetime mismatch can accidentally keep request-scoped state alive far longer than intended.
How DI Connects Controller, Service, Repository, and Database
DI becomes easiest to understand when you stop looking at isolated classes and follow one real request through the application. A common ASP.NET Core flow is:
HTTP Request
|
v
Controller
|
v
Application Service
|
v
Repository
|
v
SQL Server
The controller handles HTTP details. The service contains application or business rules. The repository owns SQL and persistence logic. DI connects the layers without forcing one layer to manually construct the next. This structure is not a rule that every project must follow. A small application can be simpler. The useful principle is that dependencies should point toward clear abstractions and object construction should stay out of business logic.
DI does not create architecture by itself. It only makes it easier to keep architectural boundaries clean.
Dependency Injection With a Dapper Repository
The following example shows a practical pattern for an ASP.NET Core application that uses Dapper. A connection factory owns connection creation, the repository owns SQL, the service owns application rules, and the controller owns HTTP behavior.
public interface IDbConnectionFactory
{
IDbConnection CreateConnection();
}
public class SqlConnectionFactory : IDbConnectionFactory
{
private readonly string _connectionString;
public SqlConnectionFactory(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string not found.");
}
public IDbConnection CreateConnection()
=> new SqlConnection(_connectionString);
}
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken);
}
public class ProductRepository : IProductRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public ProductRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<Product?> GetByIdAsync(
int id,
CancellationToken cancellationToken)
{
const string sql = @"SELECT ProductId, Name, Price
FROM Products
WHERE ProductId = @Id";
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<Product>(
new CommandDefinition(sql, new { Id = id },
cancellationToken: cancellationToken));
}
}
public interface IProductService
{
Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken);
}
public class ProductService : IProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public Task<Product?> GetByIdAsync(
int id,
CancellationToken cancellationToken)
{
if (id <= 0)
return Task.FromResult<Product?>(null);
return _repository.GetByIdAsync(id, cancellationToken);
}
}
builder.Services.AddSingleton<IDbConnectionFactory, SqlConnectionFactory>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
The connection factory can be a singleton because it only stores an immutable connection string and creates a fresh connection when asked. The repository and service are scoped because they participate in request work. The actual database connection is opened for the operation and disposed after use. The main benefit is clarity. ProductService does not know about SQL Server or Dapper, and ProductsController does not know how products are stored. Each class has one understandable job.
Do You Need an Interface for Every Service?
No. DI and interfaces are related, but they are not the same thing. ASP.NET Core can inject a concrete class directly. Interfaces are valuable when they provide a real boundary or allow more than one implementation. For example, a repository interface is often useful because the application layer should not care whether data comes from Dapper, Entity Framework Core, an API, or a fake test implementation. A payment gateway interface is useful because providers may change. A tiny formatter class used in one place may not need an interface at all.
Use abstractions where they improve replaceability or boundaries. Avoid abstraction for abstraction's sake.
Dependency Injection vs Inversion of Control vs Dependency Inversion
These three terms are often mixed together, but they describe different ideas. Understanding the difference makes architecture discussions much clearer.
In an ASP.NET Core application, the built-in container provides IoC behavior, constructor injection is the DI technique, and an interface such as IProductRepository can help apply the Dependency Inversion Principle by separating business code from database implementation details. You can use DI without designing your architecture well. DI is a tool; DIP is a design principle.
Avoid Captive Dependencies and Hidden State
A captive dependency happens when a long-lived service holds a shorter-lived service for too long. The classic example is a singleton that receives a scoped repository in its constructor.
// Avoid this combination.
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddSingleton<OrderCache>();
public class OrderCache
{
private readonly IOrderRepository _repository;
public OrderCache(IOrderRepository repository)
{
_repository = repository;
}
}
The singleton lives for the application lifetime, while the repository is supposed to live only inside a request scope. Keeping the scoped object inside the singleton breaks that expectation and can create incorrect state, disposal problems, or concurrency bugs. The simplest fix is usually to reconsider the design and align lifetimes. If a singleton truly needs to perform work using scoped services, create an explicit scope for that unit of work rather than storing the scoped service.
Safe direction: A scoped service can use singleton dependencies. A singleton should not directly capture scoped dependencies.
Using Scoped Services From a Hosted Service
Hosted services such as BackgroundService are registered as singletons. That means you should not inject a scoped repository directly into a background worker constructor. Instead, inject IServiceScopeFactory, create a scope for each unit of work, resolve the scoped service inside that scope, and dispose the scope when the work finishes.
public class BillingWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public BillingWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var billingService = scope.ServiceProvider
.GetRequiredService<IBillingService>();
await billingService.ProcessPendingAsync(stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
This is one of the few situations where resolving a service from a service provider is intentional. The code is managing a lifetime boundary, not hiding ordinary business dependencies.
When One Interface Has More Than One Implementation
Sometimes one contract has several valid implementations. You may have email, SMS, and push notification senders, or several pricing strategies. ASP.NET Core can register multiple implementations of the same interface.
builder.Services.AddScoped<INotificationSender, EmailNotificationSender>();
builder.Services.AddScoped<INotificationSender, SmsNotificationSender>();
public class NotificationDispatcher
{
private readonly IEnumerable<INotificationSender> _senders;
public NotificationDispatcher(IEnumerable<INotificationSender> senders)
{
_senders = senders;
}
}
Requesting IEnumerable<INotificationSender> gives the consumer all registered implementations. This works well when the consumer wants to inspect or execute several strategies. Modern .NET also supports keyed services, introduced in .NET 8. A key can identify a specific implementation when several services share the same contract.
builder.Services.AddKeyedScoped<INotificationSender, EmailNotificationSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsNotificationSender>("sms");
public class AlertService
{
private readonly INotificationSender _sender;
public AlertService(
[FromKeyedServices("email")] INotificationSender sender)
{
_sender = sender;
}
}
Do not use keyed services just to avoid designing a clear strategy. They are useful when the key is a real part of how the application selects an implementation.
Use a Factory When Construction Needs Extra Logic
Most services can be registered by mapping an interface to an implementation. Sometimes construction needs an extra value or a small amount of logic. In that case, registration can use a factory delegate.
builder.Services.AddSingleton<IClock>(sp =>
{var logger = sp.GetRequiredService<ILogger<SystemClock>>();
return new SystemClock(logger, TimeZoneInfo.Utc);
});
The factory still allows the container to supply other registered dependencies. Use this for construction concerns, not for business decision-making. If the factory becomes large or contains many branches, it is often a sign that the construction logic deserves its own abstraction.
Inject Configuration With the Options Pattern
Services often need configuration such as API endpoints, timeout values, or email settings. Reading raw configuration keys throughout the application creates string-based dependencies and makes settings harder to validate. The options pattern gives configuration a strongly typed model that can be injected like any other dependency.
public class EmailSettings
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
}
builder.Services.Configure<EmailSettings>(
builder.Configuration.GetSection("Email"));
public class EmailSender : IEmailSender
{
private readonly EmailSettings _settings;
public EmailSender(IOptions<EmailSettings> options)
{
_settings = options.Value;
}
}
This keeps configuration access near the service that needs it while avoiding repeated calls such as configuration["Email:Host"]. For production systems, options can also be validated during startup so configuration problems are detected early.
Why DI Makes Unit Testing Easier
DI improves testability because a class depends on a contract rather than constructing infrastructure directly. A unit test can supply a fake repository that behaves exactly how the test needs, without opening a database connection.
public class FakeProductRepository : IProductRepository
{
public Task<Product?> GetByIdAsync(
int id,
CancellationToken cancellationToken)
{
var product = id == 10
? new Product { ProductId = 10, Name = "Keyboard", Price = 50 }
: null;
return Task.FromResult(product);
}
}
[Fact]
public async Task GetByIdAsync_ReturnsProduct_WhenProductExists()
{
var repository = new FakeProductRepository();
var service = new ProductService(repository);
var result = await service.GetByIdAsync(10, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("Keyboard", result!.Name);
}
Notice that the test does not need the ASP.NET Core DI container at all. The service is an ordinary C# class whose dependency is passed directly to its constructor. That is a healthy sign: DI should make classes easier to compose, not make them dependent on the container. Integration tests can still build the real application and replace selected registrations when needed, but simple unit tests should usually create the class directly.
Dependency Injection Mistakes to Avoid
DI can improve design, but it can also hide poor design if it is used mechanically. The following mistakes are common in real ASP.NET Core projects.
- 1.Injecting too many dependencies into one constructor. If a service needs eight or ten unrelated services, the class may be doing too much. Split responsibilities before blaming DI.
- Using `IServiceProvider` as a service locator inside normal business code. This hides required dependencies because the constructor no longer tells the full story.
- Making everything a singleton for performance. Singleton is a lifetime choice, not a performance shortcut. Shared mutable state can create difficult concurrency bugs.
- Injecting a scoped dependency into a singleton. This is a lifetime mismatch and can keep requestspecific state alive for too long.
- Creating dependencies manually with `new` inside services even after registering them with DI. This bypasses the container and defeats the purpose of central composition.
- Adding an interface for every class without a design reason. More abstractions do not automatically mean cleaner architecture.
- Putting expensive work in constructors. Constructors should establish object state. Database calls, network calls, or long-running work should happen in explicit methods.
- Registering a service but forgetting its dependencies. ASP.NET Core can only construct the full object graph when every required service is registered or otherwise constructible.
- Ignoring disposal and lifetime ownership. Let the container dispose services it creates, and dispose resources you create manually at the correct boundary.
The best DI code is boring: dependencies are obvious, lifetimes are intentional, registration is centralized, and business classes do not know the container exists.
A Clean ASP.NET Core DI Structure to Remember
Program.cs / Composition Root
|
| registers
v
Controllers
|
v
Application Services
|
v
Repository Interfaces
|
v
Dapper Repositories
|
v
Connection Factory
|
v
SQL Server
Cross-cutting dependencies:
ILogger<T> | IOptions<T> | HttpClient | Cache | Authentication
The startup layer is often called the composition root because it is where the application decides which implementations to compose together. Controllers and services should not need to know that IProductRepository maps to ProductRepository; they only need the contract. For a typical ASP.NET Core business application, a practical starting point is simple: controllers depend on application services, application services depend on repositories or other domain-facing abstractions, and repositories depend on database infrastructure. Register those services with deliberate lifetimes and keep the container out of business logic.
- Keep controllers thin and focused on HTTP.
- Keep business rules in application or domain services.
- Keep SQL inside repositories or the data-access layer.
- Use constructor injection for required dependencies.
- Use scoped lifetime for request-oriented services and repositories unless there is a reason not to.
- Use singletons only for services designed to be shared safely.
- Use interfaces where they create a meaningful boundary.
- Create explicit scopes when singleton infrastructure must perform scoped work.
- Test business classes by passing fakes or mocks directly.
- Treat `Program.cs` registrations as part of your architecture, not random startup boilerplate.
Final Thoughts
Dependency Injection is not mainly about removing the new keyword. It is about deciding where object creation belongs and keeping business code independent from construction details. In ASP.NET Core, the built-in container makes the mechanics straightforward. The harder and more valuable part is choosing clear boundaries and correct lifetimes. Start with constructor injection. Register services centrally. Use scoped services for request work, singleton services only when they are truly safe to share, and transient services for lightweight stateless behavior when a fresh instance is appropriate. Do not add abstractions simply to make the code look architectural. A good DI design should make the code easier to read. When you open a class, its constructor should tell you what it needs, its methods should focus on its real responsibility, and its implementation should be replaceable without rewriting unrelated parts of the system. Once that idea becomes natural, DI stops feeling like a framework feature and starts feeling like normal, clean object-oriented design.
Dependency Injection is successful when your classes depend on what they need, not on how those dependencies are built.