Publishing Bi-Weekly · ASP.NET Core · Design Patterns · Architecture · 20 yrs C#/.NET · Jacksonville, FL · appliedcsharp.com

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

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<OrderDto> CreateOrderAsync(OrderDto dto)
    {
        var order = new Order
        {
            CustomerName = dto.CustomerName,
            Total = dto.Items.Sum(i => 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;
    }

    [HttpPost]
    public async Task<IActionResult> 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 = "Test User",
        Items = new List<OrderItemDto> { new() { Price = 99.99m } }
    });

    Assert.IsType<CreatedAtActionResult>(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:

  1. First request comes in, ASP.NET Core creates CacheService
  2. It gives CacheService the DbContext from that first request
  3. Request finishes, but CacheService is still holding onto that DbContext
  4. That DbContext never gets cleaned up (memory leak)
  5. Every future request uses a dead DbContext
  6. You get memory leaks, weird bugs, and random failures

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<ApplicationDbContext> dbFactory)
    {
        _dbFactory = dbFactory;
    }

    public async Task<Order?> 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: ASP.NET Core catches captive dependencies automatically. In Development mode, scope validation is enabled by default — if a Singleton depends on a Scoped service, the app throws an InvalidOperationException at startup before any request is served. Never ignore that error.


🧪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 = "Test User",
        Items = new List<OrderItemDto> { new() { Price = 49.99m } }
    });

    // Assert
    var createdResult = Assert.IsType<CreatedAtActionResult>(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.