.NET & C# Development · Lesson 6 of 92
Understand DI by Building It Without a Framework First
What Problem Does DI Actually Solve?
Without DI, classes create their own dependencies. That creates tight coupling.
C#
// 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.
C#
// 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:
C#
[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.
C#
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
C#
// 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
C#
// 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
C#
[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:
C#
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).
C#
// 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
C#
// 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
C#
// 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