Back to blog
Backend Systemsbeginner

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.

LearnixoApril 14, 20266 min read
.NETC#ASP.NET CoreDependency InjectionDesign Patterns
Share:𝕏

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 OrderService without hitting a real database, Stripe, and SendGrid
  • Changing the email provider means editing OrderService
  • OrderService knows 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 both

Constructor 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 services

What 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.