From Zero to Hero: Dependency Injection in .NET with C#
Master Dependency Injection in .NET with this comprehensive course by Nick Chapsas. Learn how to build cleaner, more maintainable code, optimize testability, and tackle real-world projects.
Good DI makes code easier to change and easier to test. In .NET you get a container out of the box, so you don't need extra packages to start. The idea is simple: depend on abstractions, register implementations once, and let the runtime supply them where needed.
This guide focuses on the built‑in container, Minimal APIs, and practical code you can paste into a project today.
IServiceCollection.IServiceProvider to resolve them.No factories sprinkled through the code, no new chains inside your endpoints.
| Lifetime | Created | Typical use | Notes |
|---|---|---|---|
| Transient | Every resolve | Stateless mappers, formatters, small services | Short‑lived; cheap to create |
| Scoped | Once per request | Repositories, DbContext, units of work | The default for web request‑bound work |
| Singleton | Once per app | Caches, config readers, heavy single instances | Avoid capturing scoped services (captive dependency) |
Rule of thumb: start Scoped for request‑bound services, Transient for tiny helpers, Singleton for pure stateless services that are safe to share (thread‑safe + no per‑request state).
Program.cs:
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
// registrations
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<CreateOrderHandler>();
services.AddSingleton<ISystemClock, SystemClock>(); // safe singleton
services.AddDbContext<AppDbContext>(o => o.UseNpgsql(
builder.Configuration.GetConnectionString("Default")));
var app = builder.Build();
// endpoints
app.MapPost("/orders", async Task<IResult> (CreateOrderRequest req, CreateOrderHandler handler) =>
{
var id = await handler.Handle(req);
return Results.Created($"/orders/{id}", new { id });
});
app.MapGet("/orders/{id:guid}", async Task<IResult> (Guid id, IOrderRepository repo) =>
{
var dto = await repo.GetDetailsAsync(id);
return dto is null ? Results.NotFound() : Results.Ok(dto);
});
app.Run();
public sealed class CreateOrderHandler(IOrderRepository repo, ISystemClock clock)
{
public async Task<Guid> Handle(CreateOrderRequest req)
{
var now = clock.UtcNow;
var order = Order.Create(req.CustomerId, now, req.Lines);
await repo.AddAsync(order);
return order.Id;
}
}
This class has no idea about DI. It only knows about its interfaces.
services.AddTransient<ISlugGenerator, SlugGenerator>();
services.AddScoped<IEmailSender, SmtpEmailSender>();
services.AddSingleton<ISystemClock, SystemClock>(); // wraps DateTime.UtcNow
// open generics
services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
// named/typed HttpClient (uses built-in factory)
services.AddHttpClient("github", c =>
{
c.BaseAddress = new Uri("https://api.github.com");
c.DefaultRequestHeaders.UserAgent.ParseAdd("HelloApi/1.0");
});
Yes, you can resolve services manually:
var sp = app.Services;
using var scope = sp.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
Where this is okay: at the composition root (startup, background wiring, one‑off tasks).
Where to avoid it: inside business classes. It hides dependencies and complicates tests. Prefer constructor injection.
If a Singleton depends on a Scoped service, the scoped instance may be cached for the whole app lifetime — bad news for things like DbContext.
services.AddSingleton<ReportCache>(); // BAD if ReportCache needs DbContext
services.AddScoped<AppDbContext>();
Fixes:
public sealed class ReportCache(Func<AppDbContext> dbFactory)
{
public async Task<int> CountAsync()
{
using var db = dbFactory();
return await db.Orders.CountAsync();
}
}
And register:
services.AddScoped<ReportCache>();
services.AddScoped<Func<AppDbContext>>(sp => () => sp.GetRequiredService<AppDbContext>());
builder.Services.Configure<MailOptions>(
builder.Configuration.GetSection("Mail"));
public sealed class MailOptions
{
public string Host { get; init; } = "";
public int Port { get; init; }
}
public sealed class SmtpEmailSender(IOptions<MailOptions> options)
: IEmailSender
{
private readonly MailOptions _opt = options.Value;
// use _opt.Host / _opt.Port
}
If you expect changes at runtime, inject IOptionsSnapshot<T> (scoped) or IOptionsMonitor<T> (singleton with change notifications).
Create a handler with fakes; no container needed.
public sealed class FakeClock(DateTime fixedUtc) : ISystemClock
{
public DateTime UtcNow { get; } = fixedUtc;
}
[Fact]
public async Task Creates_order_with_now()
{
var repo = new InMemoryOrderRepository();
var clock = new FakeClock(new DateTime(2025, 1, 1, 12, 0, 0, DateTimeKind.Utc));
var sut = new CreateOrderHandler(repo, clock);
var id = await sut.Handle(new CreateOrderRequest(/* ... */));
var saved = await repo.GetDetailsAsync(id);
saved!.CreatedAtUtc.ShouldBe(clock.UtcNow); // Shouldly
}
If you prefer to spin up the container in tests, use WebApplicationFactory or build a small ServiceCollection and call BuildServiceProvider().
Decorator without extra libraries:
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IOrderRepository>(sp =>
{
var inner = sp.GetRequiredService<EfOrderRepository>();
var log = sp.GetRequiredService<ILogger<OrderRepositoryLoggingDecorator>>();
return new OrderRepositoryLoggingDecorator(inner, log);
});
public sealed class OrderRepositoryLoggingDecorator(IOrderRepository inner, ILogger logger)
: IOrderRepository
{
public async Task<OrderDto?> GetDetailsAsync(Guid id)
{
logger.LogInformation("Fetching order {Id}", id);
return await inner.GetDetailsAsync(id);
}
// pass-through other members
}
Background service that uses scoped services:
public sealed class CleanupWorker(IServiceScopeFactory scopeFactory, ILogger<CleanupWorker> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await CleanupAsync(db, ct);
await Task.Delay(TimeSpan.FromMinutes(10), ct);
}
}
}
Register it with services.AddHostedService<CleanupWorker>();
© 2026 Dometrain. All rights reserved.