Understand DI by Building It Without a Framework First
Grasp dependency injection from first principles — manual constructor injection, then the ASP.NET Core container, service registration lifetimes, and why DI makes your code testable.
What Problem Does DI Actually Solve?
Without DI, classes create their own dependencies. That creates tight coupling.
// BEFORE — OrderService creates everything it needs
public class OrderService
{
private readonly SqlOrderRepository _repo;
private readonly SendGridEmailService _email;
private readonly StripePaymentService _payment;
public OrderService()
{
// Hard-coded implementations — can't swap for tests, can't change providers
_repo = new SqlOrderRepository("Server=localhost;...");
_email = new SendGridEmailService("SG.mykey");
_payment = new StripePaymentService("sk_live_abc");
}
public async Task<Order> CreateAsync(CreateOrderRequest req)
{
var order = await _repo.SaveAsync(req);
await _payment.ChargeAsync(order.Total, req.PaymentToken);
await _email.SendConfirmationAsync(order);
return order;
}
}Problems:
- Can't test
OrderServicewithout hitting a real database, Stripe, and SendGrid - Changing the email provider means editing
OrderService OrderServiceknows about SQL connection strings — not its job
Step 1 — Manual Constructor Injection
The fix is simple: accept dependencies as constructor parameters.
// Define interfaces (contracts)
public interface IOrderRepository
{
Task<Order> SaveAsync(CreateOrderRequest req);
Task<Order?> GetByIdAsync(int id);
}
public interface IEmailService
{
Task SendConfirmationAsync(Order order);
}
public interface IPaymentService
{
Task ChargeAsync(decimal amount, string token);
}
// OrderService depends on abstractions, not concrete types
public class OrderService
{
private readonly IOrderRepository _repo;
private readonly IEmailService _email;
private readonly IPaymentService _payment;
public OrderService(
IOrderRepository repo,
IEmailService email,
IPaymentService payment)
{
_repo = repo;
_email = email;
_payment = payment;
}
public async Task<Order> CreateAsync(CreateOrderRequest req)
{
var order = await _repo.SaveAsync(req);
await _payment.ChargeAsync(order.Total, req.PaymentToken);
await _email.SendConfirmationAsync(order);
return order;
}
}Now you can test with fakes:
[Fact]
public async Task CreateAsync_SendsConfirmationEmail()
{
var repo = new FakeOrderRepository();
var email = new FakeEmailService();
var payment = new FakePaymentService();
var svc = new OrderService(repo, email, payment);
await svc.CreateAsync(new CreateOrderRequest(...));
Assert.True(email.ConfirmationWasSent);
}Step 2 — Build a Tiny Manual Container
Before looking at ASP.NET Core's container, build a minimal one yourself so the concept clicks.
public class TinyContainer
{
private readonly Dictionary<Type, Func<object>> _registrations = new();
public void Register<TService, TImplementation>()
where TImplementation : TService, new()
{
_registrations[typeof(TService)] = () => new TImplementation();
}
public void RegisterSingleton<TService>(TService instance)
where TService : notnull
{
_registrations[typeof(TService)] = () => instance;
}
public T Resolve<T>()
{
if (_registrations.TryGetValue(typeof(T), out var factory))
return (T)factory();
throw new InvalidOperationException($"No registration for {typeof(T).Name}");
}
}
// Usage
var container = new TinyContainer();
container.Register<IOrderRepository, SqlOrderRepository>();
container.Register<IEmailService, SendGridEmailService>();
container.Register<IPaymentService, StripePaymentService>();
var repo = container.Resolve<IOrderRepository>();
var email = container.Resolve<IEmailService>();
var svc = new OrderService(repo, email, container.Resolve<IPaymentService>());ASP.NET Core's IServiceProvider does exactly this — but automatically resolves the full dependency graph.
ASP.NET Core's DI Container
// Program.cs — register services
var builder = WebApplication.CreateBuilder(args);
// Map interface -> implementation
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IEmailService, SendGridEmailService>();
builder.Services.AddScoped<IPaymentService, StripePaymentService>();
builder.Services.AddScoped<IOrderService, OrderService>();
var app = builder.Build();
app.MapControllers();
app.Run();The container automatically resolves OrderService's three constructor parameters when something asks for IOrderService. You never new anything manually.
Registering Services — AddTransient / AddScoped / AddSingleton
// Transient — new instance every time it's requested
builder.Services.AddTransient<IEmailService, SendGridEmailService>();
// Scoped — one instance per HTTP request
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
// Singleton — one instance for the entire app lifetime
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddSingleton<IFeatureFlags, LaunchDarklyFeatureFlags>();
// Register a concrete class without an interface
builder.Services.AddScoped<TokenService>();
// Register with a factory (when construction needs logic)
builder.Services.AddScoped<IOrderRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var logger = sp.GetRequiredService<ILogger<SqlOrderRepository>>();
return new SqlOrderRepository(config.GetConnectionString("Default")!, logger);
});
// Register multiple implementations of the same interface
builder.Services.AddScoped<INotificationSender, EmailNotificationSender>();
builder.Services.AddScoped<INotificationSender, SmsNotificationSender>();
// IEnumerable<INotificationSender> will contain bothConstructor Injection in Controllers
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orders;
private readonly ILogger<OrdersController> _logger;
// ASP.NET Core resolves these automatically
public OrdersController(
IOrderService orders,
ILogger<OrdersController> logger)
{
_orders = orders;
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest req,
CancellationToken ct)
{
_logger.LogInformation("Creating order for customer {CustomerId}", req.CustomerId);
var order = await _orders.CreateAsync(req, ct);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(id, ct);
return order is null ? NotFound() : Ok(order);
}
}Constructor Injection in Services
Services inject other services the same way:
public class OrderService : IOrderService
{
private readonly IOrderRepository _repo;
private readonly IPaymentService _payment;
private readonly IEmailService _email;
private readonly ILogger<OrderService> _logger;
public OrderService(
IOrderRepository repo,
IPaymentService payment,
IEmailService email,
ILogger<OrderService> logger)
{
_repo = repo;
_payment = payment;
_email = email;
_logger = logger;
}
public async Task<OrderDto> CreateAsync(CreateOrderRequest req, CancellationToken ct)
{
_logger.LogInformation("Processing order for customer {Id}", req.CustomerId);
var order = Order.Create(req.CustomerId, req.Items);
await _repo.AddAsync(order, ct);
await _payment.ChargeAsync(order.Total, req.PaymentToken, ct);
_logger.LogInformation("Payment charged for order {OrderId}", order.Id);
await _email.SendConfirmationAsync(order, ct);
return order.ToDto();
}
}GetRequiredService vs GetService
Prefer constructor injection. Only use IServiceProvider directly when you can't avoid it (e.g. factories).
// GetRequiredService — throws if not registered (preferred)
var repo = serviceProvider.GetRequiredService<IOrderRepository>();
// GetService — returns null if not registered (use when optional)
var cache = serviceProvider.GetService<ICacheService>();
if (cache is not null)
await cache.SetAsync(key, value);
// Resolve all registrations for an interface
var senders = serviceProvider.GetServices<INotificationSender>();
foreach (var sender in senders)
await sender.SendAsync(message);Why DI Makes Testing Easy
// Fake implementations for tests — no network, no database
public class FakeOrderRepository : IOrderRepository
{
private readonly List<Order> _store = new();
public Task<Order> SaveAsync(CreateOrderRequest req, CancellationToken ct = default)
{
var order = new Order { Id = _store.Count + 1, CustomerId = req.CustomerId };
_store.Add(order);
return Task.FromResult(order);
}
public Task<Order?> GetByIdAsync(int id, CancellationToken ct = default)
=> Task.FromResult(_store.FirstOrDefault(o => o.Id == id));
}
public class FakePaymentService : IPaymentService
{
public bool WasCharged { get; private set; }
public decimal ChargedAmount { get; private set; }
public Task ChargeAsync(decimal amount, string token, CancellationToken ct = default)
{
WasCharged = true;
ChargedAmount = amount;
return Task.CompletedTask;
}
}
// Test — zero external dependencies
[Fact]
public async Task CreateOrder_ChargesCorrectAmount()
{
var repo = new FakeOrderRepository();
var payment = new FakePaymentService();
var email = new FakeEmailService();
var logger = NullLogger<OrderService>.Instance;
var svc = new OrderService(repo, payment, email, logger);
var req = new CreateOrderRequest(CustomerId: 1, Items: new[]
{
new OrderItemRequest(ProductId: 5, Quantity: 2, UnitPrice: 49.99m)
}, PaymentToken: "tok_test");
await svc.CreateAsync(req, CancellationToken.None);
Assert.True(payment.WasCharged);
Assert.Equal(99.98m, payment.ChargedAmount);
}Common Mistakes
// MISTAKE 1 — injecting IServiceProvider to resolve manually (service locator anti-pattern)
public class OrderService
{
private readonly IServiceProvider _sp;
public OrderService(IServiceProvider sp) => _sp = sp;
public async Task DoWork()
{
var repo = _sp.GetRequiredService<IOrderRepository>(); // don't do this
}
}
// MISTAKE 2 — using HttpContext or IHttpContextAccessor in a singleton
// Singletons outlive requests — HttpContext is request-scoped
// MISTAKE 3 — constructor with too many parameters (more than 4-5 = class doing too much)
public class GodService(IRepo1 r1, IRepo2 r2, IRepo3 r3, IRepo4 r4,
IRepo5 r5, IService1 s1, IService2 s2, ILogger<GodService> log)
// Refactor into smaller, focused servicesWhat to Learn Next
- Service Lifetimes: Transient vs Scoped vs Singleton — the captive dependency bug
- Keyed Services: Register multiple implementations of the same interface
- Scrutor: Auto-register 50 services with a single Scan() call
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.