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);
}
🏗️ 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 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 });
}
[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>(), 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 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:
- First request comes in, ASP.NET Core creates
CacheService - It gives
CacheServicetheDbContextfrom that first request - Request finishes, but
CacheServiceis still holding onto thatDbContext - That
DbContextnever gets cleaned up (memory leak) - Every future request uses a dead DbContext
- 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
InvalidOperationExceptionat 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
() , notAddScoped - 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.