Table of Contents
TL;DR
- Decouples your code and makes testing lightning-fast (3-second tests vs 30-second ones)
- Follows SOLID principles (especially the D in dependency inversion)
- Works everywhere - controllers, middleware, hosted services, you name it
- Use
Scoped
lifetime for most dependencies (safest choice) - Avoid over-injection - keep ≤5 constructor parameters or refactor
- Think power socket - plug in any device as long as it matches the interface
We’ve all been there, staring at a controller that directly instantiates services, which directly instantiate repositories, which connect straight to databases. Everything’s hardcoded, brittle, and painful to test. Changing one thing breaks three others. You can’t mock anything, and testing feels like disarming a bomb blindfolded.
Here’s the deal: tight coupling is the enemy of maintainable software.
Constructor injection in ASP.NET Core isn’t just theory, it’s how you create controllers that don’t break when you change database providers, services you can test without waiting for API calls, and systems that don’t collapse when requirements change.
Think of constructor injection like a power socket, plug in any device as long as it matches the interface. Your controller doesn’t care if it’s getting a SQL repository or an in-memory one, just like your socket doesn’t care if you plug in a lamp or a phone charger.
Let’s see exactly how to implement it in real ASP.NET Core 8 applications.
The Layered Architecture Setup
Most ASP.NET Core apps follow this pattern:
- Controller: Handles HTTP
- Service: Handles business logic
- Repository: Handles data access
graph LR Controller[Controller] --> Service[Service] Service --> Repository[Repository] Repository --> Database[(Database)] classDef component fill:#bbf,stroke:#333,stroke-width:1px classDef database fill:#bfb,stroke:#333,stroke-width:1px class Controller,Service,Repository component class Database database
Traditional layered architecture flow in ASP.NET Core applications
Without constructor injection, this becomes a mess real fast:
// Tightly coupled - don't do this!
public class ProductController : ControllerBase
{
public IActionResult GetProduct(int id)
{
var repository = new SqlProductRepository(); // Hardcoded dependency
var service = new ProductService(repository);
var product = service.GetById(id);
return Ok(product);
}
}
This controller knows too much. It shouldn’t care what repo or service implementation is being used.
A few years back, we had a controller that spun up its own service and repo just like this.
One small change to the repo interface broke 6 controllers.
Took a whole weekend to fix it, only because no one used constructor injection.
Escape Coupling with Constructor Injection
Now let’s decouple with constructor injection.
public class ProductController : ControllerBase
{
private readonly IProductService _productService;
public ProductController(IProductService productService)
{
_productService = productService;
}
public IActionResult GetProduct(int id)
{
var product = _productService.GetById(id);
return Ok(product);
}
}
public class ProductService : IProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public Product GetById(int id)
{
return _repository.FindById(id);
}
}
Now each layer depends on abstractions, not concrete implementations. This unlocks three game-changing benefits (we’ll dive deeper into each throughout this post):
- Mock services in tests - Write 20+ controller tests in seconds without database calls
- Swap implementations - Change from SQL to NoSQL without touching controllers
- Extend without breaking - Add caching or logging without modifying existing code
See the D in SOLID for why this matters.
Wiring It All Together in Program.cs
Your DI setup in modern ASP.NET Core looks like this:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IProductRepository, SqlProductRepository>();
var app = builder.Build();
AddScoped
: New instance per request. Best for services and repositories.AddTransient
: New instance every time (used rarely).AddSingleton
: Same instance for the app lifetime (use carefully, avoid for anything stateful or request-bound).
Avoid Mixing Lifetimes
Don’t inject Scoped
services (like DbContext
) into Singleton
middleware.
This violates the DI container’s contract and will lead to runtime errors or weird behavior.
Don’t do this. Seriously. I’ve seen apps break just because someone injected DbContext
into a singleton.
It worked fine in dev, then failed silently under load in staging, and cost us hours of debugging.
If your middleware needs scoped services, resolve them via IServiceProvider
in the InvokeAsync
method.
Pro tip: If you’re not sure which lifetime to use, start with
Scoped
.
It’s safe for most services unless you know you needSingleton
orTransient
.
Constructor Injection vs Other Patterns
Quick comparison:
Pattern | Pros | Cons |
---|---|---|
Constructor | Explicit, testable, reliable | May get bloated (see below) |
Property | Optional/hidden, easy to miss | Poor discoverability |
Method | Useful for short-lived deps | Not composable |
Service Locator | Flexible at runtime | Anti-pattern: kills testability |
Constructor injection makes dependencies clear from the start. No guessing games.
Constructor Injection Beyond Controllers
DI isn’t limited to controllers. It works everywhere ASP.NET Core touches:
IMiddleware
public class LoggingMiddleware : IMiddleware
{
private readonly ILogger<LoggingMiddleware> _logger;
public LoggingMiddleware(ILogger<LoggingMiddleware> logger)
{
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
_logger.LogInformation("Incoming request");
await next(context);
}
}
IHostedService
public class JobScheduler : IHostedService
{
private readonly IJobRunner _jobRunner;
public JobScheduler(IJobRunner jobRunner)
{
_jobRunner = jobRunner;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_jobRunner.ScheduleJobs();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Same DI rules, cleaner architecture.
Testing Is Where It Really Shines
Constructor injection makes tests trivial (as mentioned in our benefits above).
[Test]
public void GetProduct_ReturnsProduct_WhenIdExists()
{
var mockService = new Mock<IProductService>();
mockService.Setup(s => s.GetById(1)).Returns(new Product { Id = 1, Name = "Test" });
var controller = new ProductController(mockService.Object);
var result = controller.GetProduct(1);
Assert.IsInstanceOf<OkObjectResult>(result);
}
Real-world Tip: We once caught a subtle caching bug only because we could unit test a service in isolation. If we had hardcoded dependencies, that bug would’ve made it to production.
No real database. No infrastructure. Just clean, fast tests that make dependencies explicit and enforce single responsibility.
Constructor Over-Injection = Code Smell
If you see this:
public ProductService(IProductRepository repo, IEmailService email,
ILoggingService logger, ICacheService cache,
IValidationService validator, INotificationService notify)
{
// This class is doing way too much
}
That’s a red flag. Your service is a God object.
Here’s how to fix it by breaking it down:
// ✅ Focused service
public class ProductService : IProductService
{
private readonly IProductRepository _repo;
private readonly INotifier _notifier;
public ProductService(IProductRepository repo, INotifier notifier)
{
_repo = repo;
_notifier = notifier;
}
}
// ✅ Use orchestrator for complex workflows
public class PurchaseOrchestrator : IPurchaseOrchestrator
{
private readonly IProductService _productSvc;
private readonly IPaymentService _paymentSvc;
public PurchaseOrchestrator(IProductService productSvc, IPaymentService paymentSvc)
{
_productSvc = productSvc;
_paymentSvc = paymentSvc;
}
}
Rule of Thumb: If a class has more than 4-5 dependencies, it’s time to refactor into focused units.
Dependency Flow Visualization
graph TD HTTP[HTTP Request] --> CTRL\[ProductController] DI\[DI Container] -.-> |Injects| CTRL CTRL --> SVC\[IProductService] DI -.-> |Injects| SVC SVC --> REPO\[IProductRepository] DI -.-> |Injects| REPO REPO --> DB\[(Database)] ``` classDef container fill:#f9f,stroke:#333,stroke-width:2px classDef component fill:#bbf,stroke:#333,stroke-width:1px classDef database fill:#bfb,stroke:#333,stroke-width:1px class DI container class CTRL,SVC,REPO component class DB database ```
Dependency Flow in ASP.NET Core: The DI container injects services into controllers, services, and repositories, enabling clean, decoupled, and testable architecture.
Why Domain-Driven Design Works Better with Constructor Injection
Constructor injection makes proper separation of concerns actually work in practice:
- Domain logic stays pure - Your business rules don’t import SQL packages or HTTP client libraries
- Infrastructure details stay at the edges - Replace Entity Framework with Dapper without touching domain classes
- Testing becomes practical - Run 100+ tests in seconds without spinning up databases or APIs
Quick Implementation Checklist
- Define interfaces for services and repositories
- Use constructor injection, not
new
- Register everything with the right lifetime in
Program.cs
- Keep constructors clean (≤4-5 parameters)
- Test using mocks/fakes via DI
Avoid Mixing Lifetimes
Real-world Tip: Don’t inject Scoped
services (like DbContext
) into Singleton
middleware. This violates the DI container’s contract and will cause runtime errors or weird behavior under load.
If your middleware needs scoped services, resolve them via IServiceProvider
in the InvokeAsync
method.
Pro tip: If you’re not sure which lifetime to use, start with
Scoped
. It’s safe for most services unless you know you needSingleton
orTransient
.
The Bottom Line
Constructor injection isn’t just a feature, it’s the foundation that lets you:
- Write unit tests that run in milliseconds instead of seconds
- Replace SQL Server with SQLite for integration tests with zero controller changes
- Add tracing or telemetry with a decorator pattern instead of changing existing services
- Ensure your app behaves correctly under high concurrency with proper scoping
When things break at 2AM on Sunday, constructor injection ensures you’re fixing one file, not seven. Your future self will thank you.
Got questions? Drop them in the comments, I’d love to hear about your DI wins (or horror stories)!
Frequently Asked Questions
What is constructor injection in ASP.NET Core?
Why is constructor injection considered a clean architecture decision?
When should I use AddScoped, AddSingleton, or AddTransient?
Can constructor injection be used outside controllers?
IMiddleware
), background jobs (IHostedService
), and anywhere the ASP.NET Core DI container is active.What’s the problem with injecting too many services in a constructor?
How does constructor injection improve testing?
What happens if I inject a Scoped service into a Singleton?
Is constructor injection better than the service locator pattern?
How many dependencies are too many in a constructor?
Do I always need interfaces for DI?
Additional Resources
- Dependency Injection in ASP.NET Core - Microsoft Docs
- Clean Architecture by Robert C. Martin (official blog post)
- Summary of book “Clean Architecture” by Robert C. Martin (authoritative summary)
- Dependency injection guidelines - .NET | Microsoft Learn