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
OrderServiceconstructor - 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
newis always wrong. Creating domain objects likenew Order { ... }ornew 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);
Task<OrderDto?> GetOrderAsync(int id);
}
🏗️ 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 };
}
public async Task<OrderDto?> GetOrderAsync(int id)
{
var order = await _repository.GetByIdAsync(id);
return order is null
? null
: new OrderDto { Id = order.Id, CustomerName = order.CustomerName, Total = order.Total };
}
}
🎯 3. Controllers Ask for What They Need
[ApiController]
[Route("api/[controller]")]
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("{id:int}")]
public async Task<IActionResult> GetOrder(int id)
{
var order = await _orderService.GetOrderAsync(id);
return order is null ? NotFound() : Ok(order);
}
[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 anOrderServiceand 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 });
public Task<OrderDto?> GetOrderAsync(int id)
=> Task.FromResult<OrderDto?>(new OrderDto { Id = id, CustomerName = "Test User", 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
- validators and formatters
- 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
When injected directly, DbContext should be Scoped. Register it with
AddDbContext<T>(), notAddScoped<T>()—AddDbContextconfigures the provider, connection string, and options that EF Core needs.
🏛️ Singleton - One for the entire application
builder.Services.AddSingleton<ICacheService, InMemoryCacheService>();
Created the first time it’s requested. 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):
- The first time something asks for
CacheService, the container builds it. It resolves the ScopedDbContextfrom the root scope — not from the request’s scope - That
DbContextis now effectively a Singleton. It will never be disposed until the app shuts down - Every request from then on shares that one
DbContextinstance DbContextis not thread-safe. Two requests hit it at the same time and you getInvalidOperationException: “A second operation was started on this context instance before a previous operation completed”- The change tracker grows forever — a memory leak — and serves up stale entities. You get intermittent failures that don’t reproduce locally
The rule:
Never inject a Scoped service into a Singleton.
What about Transient into Singleton? That’s legal — but the Transient lives as long as the Singleton does, so it had better be stateless and thread-safe. A caution, not a crime. Scoped into Singleton is the one that breaks apps.
✅ 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: The default builders (
WebApplication.CreateBuilder,Host.CreateDefaultBuilder) turn on bothValidateScopesandValidateOnBuildin Development. Your app fails atbuilder.Build()— at startup, before a single request — with “Cannot consume scoped service ... from singleton ...”. In Production, both checks are off by default. Want the same safety net there? Opt in throughServiceProviderOptions. And never ignore the error when it fires.
🧪 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 ApplicationDbContext(); // 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 — hand-rolled fakes like FakeOrderService work fine. Here’s the same test with NSubstitute, if you prefer a mocking library:
[Fact]
public async Task CreateOrder_ValidDto_ReturnsCreated()
{
// Arrange - create a substitute that behaves how we want
var orderService = Substitute.For<IOrderService>();
orderService.CreateOrderAsync(Arg.Any<OrderDto>())
.Returns(new OrderDto { Id = 1, CustomerName = "Test User", Total = 49.99m });
var controller = new OrderController(orderService);
// 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<T>(), 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.