Cart
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>();
Nick Chapsas is a .NET & C# content creator, educator and a Microsoft MVP for Developer Technologies with years of experience in Software Engineering and Engineering Management.
He has worked for some of the biggest companies in the world, building systems that served millions of users and tens of thousands of requests per second.
Nick creates free content on YouTube and is the host of the Keep Coding Podcast.
More courses by Nick Chapsas© 2025 Dometrain. All rights reserved.