Our Summer Sale is Live! 🎉
Everything 30% off with code SUMMER30! (Excl. Team and VS Pro)
00
Days
00
Hrs
00
Min
00
Sec
Get 30% off anything!

Getting Started with Dependency Injection in .NET

12/08/2025

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.


What DI is (in .NET terms)

  • You register services on IServiceCollection.
  • At runtime, ASP.NET Core creates an IServiceProvider to resolve them.
  • You inject dependencies via constructor (preferred) or as parameters in Minimal API handlers.

No factories sprinkled through the code, no new chains inside your endpoints.


Lifetimes cheat sheet

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).


Minimal API setup

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();
  • Handlers are plain classes; endpoints stay thin.
  • Dependencies appear as parameters and are resolved by the container.

Constructor injection in application code

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.


Service registration examples

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");
});

Avoid the service locator trap

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.


Captive dependency (common lifetime bug)

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:

  • Make the top service Scoped as well, or
  • Inject a factory instead of the scoped service:
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>());

Options: configuration as a service

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).


Testing with DI (quick sketch)

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().


Minimal recipes you’ll reuse

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>();


Final notes

  • Prefer constructor injection. It keeps dependencies visible and testable.
  • Pick lifetimes with intent; watch out for captive dependencies.
  • Keep endpoints thin; move logic to classes that accept interfaces.
  • Use the container in the composition root; avoid pulling it into business code.

About the Author

author_img

Nick Chapsas

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