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):

  1. Mock services in tests - Write 20+ controller tests in seconds without database calls
  2. Swap implementations - Change from SQL to NoSQL without touching controllers
  3. 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 need Singleton or Transient.

Constructor Injection vs Other Patterns

Quick comparison:

PatternProsCons
ConstructorExplicit, testable, reliableMay get bloated (see below)
PropertyOptional/hidden, easy to missPoor discoverability
MethodUseful for short-lived depsNot composable
Service LocatorFlexible at runtimeAnti-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 need Singleton or Transient.

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)!

About the Author

Abhinaw Kumar is a software engineer who builds real-world systems: from resilient ASP.NET Core backends to clean, maintainable Angular frontends. With over 11+ years in production development, he shares what actually works when you're shipping software that has to last.

Read more on the About page or connect on LinkedIn.

Frequently Asked Questions

What is constructor injection in ASP.NET Core?

It’s a technique where dependencies are provided to a class through its constructor, allowing better testability, decoupling, and adherence to SOLID principles.

Why is constructor injection considered a clean architecture decision?

It enforces separation of concerns, makes dependencies explicit, and enables unit testing without relying on infrastructure like databases or HTTP calls.

When should I use AddScoped, AddSingleton, or AddTransient?

Use AddScoped for most services like repositories or business logic, AddSingleton for stateless shared instances, and AddTransient for lightweight, short-lived objects.

Can constructor injection be used outside controllers?

Yes. You can use constructor injection in services, middleware (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?

It’s a code smell called “constructor over-injection” and often signals that the class has too many responsibilities. Break it down into smaller, focused services.

How does constructor injection improve testing?

It allows you to inject mocks or fakes instead of real implementations, making unit tests fast, isolated, and independent of external systems like databases or APIs.

What happens if I inject a Scoped service into a Singleton?

This causes incorrect behavior and may throw runtime exceptions. Scoped services (like DbContext) should never be injected into Singleton instances.

Is constructor injection better than the service locator pattern?

Yes. Service locator hides dependencies, making code harder to test and maintain. Constructor injection keeps dependencies explicit and promotes composability.

How many dependencies are too many in a constructor?

More than 4–5 is usually a sign to refactor. Consider using orchestration services or breaking logic into smaller, focused classes.

Do I always need interfaces for DI?

Interfaces are preferred for abstraction and testability, but in some simple cases, injecting concrete classes directly is acceptable if there’s no need to swap or mock them.

Additional Resources

Related Posts