Publishing Bi-Weekly · ASP.NET Core · Design Patterns · Architecture · 20 yrs C#/.NET · cleancsharp.com

🚀 Dependency Injection in ASP.NET Core - Stop Using 'new'

`new OrderService()` looks innocent. But now every test needs a real OrderService, a real database, and a real connection string. A test that should take 50ms takes 2 seconds and breaks when the DB blinks. Dependency injection isn't framework magic — it's a habit.

Learn why experienced C# developers avoid using 'new' in their constructors and how dependency injection makes your code testable, flexible, and professional


❌ The Wrong Way

Let’s start with what most developers do when they're learning.

Example: Creating Dependencies Manually

public class OrderController : ControllerBase
{
    private readonly OrderService _orderService;
public OrderController()
{
    _orderService = new OrderService(); // ❌ Creating it ourselves
}

[HttpPost]
public IActionResult CreateOrder(OrderDto dto)
{
    var order = _orderService.CreateOrder(dto);
    return Ok(order);
}

}

It feels logical:
“You need an OrderService, so you create one.”
But this approach creates three major problems.

🧪1. Your tests need a real database

If your controller creates its own dependencies, your tests must create them too — including database connections.
A test that should take 50 ms now takes 2 seconds and fails if the database is offline.
Multiply that by 100 tests and you're waiting minutes instead of seconds.

🔧2. Adding features means changing existing code

Say your boss wants logging added to OrderService.

Now you must:

  • Modify the OrderService constructor
  • Find every new OrderService() in your codebase
  • Add the new parameter everywhere
  • Re-test all affected files

In a real app, that could be 20+ files for one small feature.

🔄3. You can't easily switch between different implementations

Need a queue‑based order processor for Black Friday?
You’d have to manually replace every:

new OrderService()

with:

new QueuedOrderService()

And then switch it back later.

Using 'new' locks you into decisions that are hard to change.

Note: This doesn't mean new is always wrong. Creating domain objects like new Order { ... } or new List<T>() is perfectly fine — they're data, not services. The rule applies to services: objects with behavior and dependencies.


✅The Right Way

Here's how experienced developers structure their code.

🧩1. Define an Interface

public interface IOrderService
{
    Task<OrderDto> CreateOrderAsync(OrderDto dto);
}

🏗️ 2. Implement the Interface

public class OrderService : IOrderService
{
    private readonly IOrderRepository _repository;
// OrderService needs a repository, so we ask for it
public OrderService(IOrderRepository repository)
{
    _repository = repository;
}

public async Task&lt;OrderDto&gt; CreateOrderAsync(OrderDto dto)
{
    var order = new Order
    {
        CustomerName = dto.CustomerName,
        Total = dto.Items.Sum(i =&gt; i.Price)
    };

    var saved = await _repository.SaveAsync(order);

    return new OrderDto { Id = saved.Id, CustomerName = saved.CustomerName, Total = saved.Total };
}

}

🎯 3. Controllers Ask for What They Need

public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;
// We declare what we need - ASP.NET Core provides it
public OrderController(IOrderService orderService)
{
    _orderService = orderService;
}

[HttpGet(&quot;{id:int}&quot;)]
public async Task&lt;IActionResult&gt; GetOrder(int id)
{
    var order = await _orderService.GetOrderAsync(id);
    return order is null ? NotFound() : Ok(order);
}

[HttpPost]
public async Task&lt;IActionResult&gt; CreateOrder(OrderDto dto)
{
    var result = await _orderService.CreateOrderAsync(dto);
    return CreatedAtAction(nameof(GetOrder), new { id = result.Id }, result);
}

}

🛠️ 4. Register Services in Program.cs

var builder = WebApplication.CreateBuilder(args);

// "When someone asks for IOrderService, give them OrderService"
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

// Register DbContext through EF Core's pipeline — not AddScoped
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();


💡What just happened?

Instead of the controller creating its own OrderService, it simply says:

“I need something that can create orders.”

That “something” is the interface IOrderService.
Then in Program.cs, we tell ASP.NET Core:

“Whenever someone asks for an IOrderService, create an OrderService and give it to them.”

This separation is what makes DI powerful.


🧪 1. Testing Becomes Effortless

// A simple test double - no database, no mocking library needed
public class FakeOrderService : IOrderService
{
    public Task<OrderDto> CreateOrderAsync(OrderDto dto)
        => Task.FromResult(new OrderDto { Id = 1, CustomerName = dto.CustomerName, Total = 99.99m });
}

[Fact]
public async Task CreateOrder_ReturnsCreatedResult()
{
var fakeService = new FakeOrderService();
var controller = new OrderController(fakeService);

var result = await controller.CreateOrder(new OrderDto
{
    CustomerName = &quot;Test User&quot;,
    Items = new List&lt;OrderItemDto&gt; { new() { Price = 99.99m } }
});

Assert.IsType&lt;CreatedAtActionResult&gt;(result);

}

No database. No infrastructure. Just pure logic. Your tests run in milliseconds.

🔧 2. Adding Features Without Breaking Anything

Want to add logging? Update one line in Program.cs:

// Old code stays the same, just register the new version
builder.Services.AddScoped<IOrderService, OrderServiceWithLogging>();

Every controller automatically gets the new implementation.
You changed one line instead of touching 20 files.

🔄 3. Swap Implementations Instantly

Need a queue-based version for Black Friday?

// In appsettings.Production.json: "UseQueue": true

if (builder.Configuration.GetValue<bool>("UseQueue"))
{
builder.Services.AddScoped<IOrderService, QueuedOrderService>();
}
else
{
builder.Services.AddScoped<IOrderService, OrderService>();
}

A configuration switch changes the entire behavior of your application.
No code changes required.


🧬Service Lifetimes - How Long Does It Live?

When you register a service in Program.cs, you must tell ASP.NET Core how long that service should exist.

There are three lifetimes:

⚡ Transient — Create a New One Every Time

builder.Services.AddTransient<IEmailService, EmailService>();

ASP.NET Core creates a brand‑new instance every time it’s requested.

Use for:

  • lightweight operations
  • stateless helpers
  • random number generators
  • email senders
  • anything cheap to create

🔁 Scoped — One per request

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

// DbContext is registered through AddDbContext — which makes it Scoped by default
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));

ASP.NET Core creates one instance per HTTP request. Everything handling that request shares the same instance.

Use for:

  • most services
  • anything involving EF Core
  • anything where the same instance should be reused throughout a single web request

DbContext should always be scoped. Register it with AddDbContext<T>(), not AddScoped<T>()AddDbContext configures the provider, connection string, and options that EF Core needs.

🏛️Singleton - One for the entire application

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

Created once at startup. Lives until shutdown.

Use for:

  • configuration
  • caching
  • expensive objects that are safe to share
  • anything thread-safe (meaning it won't break if multiple users access it at the same time)

Rule of thumb:
When in doubt, choose Scoped.


🚨The Gotcha That Crashes Apps

Here's a mistake that causes memory leaks and weird bugs:

// ❌ DANGEROUS - Don’t do this!
builder.Services.AddSingleton<CacheService>(); // Lives forever
builder.Services.AddDbContext<ApplicationDbContext>(...); // Scoped by default

public class CacheService
{
private readonly ApplicationDbContext _db; // ⚠️ PROBLEM

public CacheService(ApplicationDbContext db)
{
    _db = db; // Scoped service captured by Singleton!
}

}

❗What’s wrong here?

CacheService is registered as a Singleton, so it gets created once and lives forever. But it has a DbContext (which is Scoped) in its constructor.

Here’s what happens (assuming scope validation is off — more on that in a moment):

  1. First request comes in. ASP.NET Core creates CacheService and injects the DbContext from that request’s scope
  2. Request finishes. The request scope tries to dispose its DbContext — but the Singleton CacheService is still rooting it, so it stays alive
  3. Future requests now share that single DbContext instance. Its underlying connection may have already been returned to the pool by the disposed scope
  4. DbContext is not thread-safe. Concurrent requests race on the same change tracker
  5. You get ObjectDisposedException, leaked connections, stale tracked entities, and intermittent failures that don’t reproduce locally

The rule:

Never inject a shorter-lifetime service into a longer-lifetime service.

  • Singleton ❌ depends on Scoped or Transient

✅The fix:

Making CacheService Scoped would fix the error — but it defeats the purpose of a cache. If it’s Scoped, you lose the cache between requests.

The real fix: use IDbContextFactory<T>, which lets a Singleton create and dispose short-lived DbContext instances on demand:

// ✅ Register the factory alongside your DbContext
builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));

builder.Services.AddSingleton<CacheService>();

public class CacheService
{
private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;

public CacheService(IDbContextFactory&lt;ApplicationDbContext&gt; dbFactory)
{
    _dbFactory = dbFactory;
}

public async Task&lt;Order?&gt; GetOrderAsync(int id)
{
    await using var db = await _dbFactory.CreateDbContextAsync();
    return await db.Orders.FindAsync(id);
}

}

The Singleton stays a Singleton. Each database operation gets a fresh, short-lived DbContext that’s created and disposed properly. No captive dependency.

Bonus: With the default host builders (WebApplication.CreateBuilder, Host.CreateDefaultBuilder), scope validation is enabled in Development. When ASP.NET Core resolves the Singleton — typically on the first request — it throws InvalidOperationException. For startup-time validation of the entire graph (catches it before any request runs), opt in with ValidateOnBuild = true in the ServiceProviderOptions. In Production, both checks are off by default — you have to enable them. Either way, never ignore the error when it does fire.


🧪Why This Matters for Testing

Without DI:

// Hard to test - creates real dependencies
public class OrderController
{
    private readonly OrderService _service;
public OrderController()
{
    var db = new AppDbContext(); // Real database connection
    var repo = new OrderRepository(db);
    _service = new OrderService(repo);
}

}

You can’t test this without a real database.
Your tests become slow, brittle, and painful.

With DI:

// Easy to test - we control what gets injected
public class OrderController
{
    private readonly IOrderService _service;
public OrderController(IOrderService service)
{
    _service = service;
}

}

And in your test:

[Fact]
public async Task CreateOrder_ValidDto_ReturnsCreated()
{
    // Arrange - create a fake that behaves how we want
    var fakeService = Substitute.For<IOrderService>();
    fakeService.CreateOrderAsync(Arg.Any<OrderDto>())
               .Returns(new OrderDto { Id = 1, CustomerName = "Test User", Total = 49.99m });
var controller = new OrderController(fakeService);

// Act
var result = await controller.CreateOrder(new OrderDto
{
    CustomerName = &quot;Test User&quot;,
    Items = new List&lt;OrderItemDto&gt; { new() { Price = 49.99m } }
});

// Assert
var createdResult = Assert.IsType&lt;CreatedAtActionResult&gt;(result);
Assert.Equal(1, ((OrderDto)createdResult.Value!).Id);

}

Fast.
Isolated.
Predictable.
Exactly what good tests should be.


🧭Key Takeaways

What you learned:

  • Use interfaces (IOrderService) instead of concrete types
  • Ask for dependencies in your constructor
  • Register services using AddScoped, AddTransient, or AddSingleton
  • Register DbContext with AddDbContext(), not AddScoped
  • Default to Scoped
  • Never inject Scoped into Singleton — use IDbContextFactory<T> when a Singleton needs database access

What this gives you:

  • Tests that run fast without real databases
  • Features added without breaking existing code
  • Easy swapping of implementations
  • Cleaner, more maintainable architecture

Next steps:

Find one controller in your code that still uses new.
Refactor it to use DI.
You’ll feel the difference immediately.

In the next post, we'll build a complete WebAPI controller using these patterns — DTOs at the boundary, async all the way, proper HTTP status codes, and structured error handling that helps instead of hiding.