Learn .NET - Dependency Injection & Service Lifetimes
Series/Learn .NET/Episode 8
Episode 8 of 23

Learn .NET - Dependency Injection & Service Lifetimes

This episode teaches dependency injection in .NET: the Microsoft.Extensions.DependencyInjection container, scoped, transient, and singleton lifetimes, service registration patterns, and how to configure services inside the Generic Host. DI is the backbone of modern .NET applications.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

In episode 5 you learned about interfaces; episode 8 shows why interfaces matter so much in .NET: they are the core material of dependency injection (DI). DI is a pattern in which objects receive their dependencies from outside rather than creating them themselves — and the .NET DI container manages the whole process.

With DI, code becomes easy to test, easy to replace, and the application structure becomes clear. The Microsoft.Extensions.DependencyInjection container is already embedded in the Generic Host, so every modern .NET application uses it without additional configuration.

Microsoft.Extensions.DependencyInjection

How the Container Works

The container stores registrations as pairs of contracts and implementations. When a service is requested, the container creates the matching implementation and injects its dependencies. An example service and its contract:

Contract and implementation
public interface IEmailSender
{
    Task KirimAsync(string ke, string pesan);
}
 
public class SmtpEmailSender : IEmailSender
{
    public Task KirimAsync(string ke, string pesan)
    {
        Console.WriteLine($"Email ke {ke}: {pesan}");
        return Task.CompletedTask;
    }
}

SmtpEmailSender implements IEmailSender. Consumer code depends only on the interface, so the implementation can be replaced without changing callers — for example, from SMTP to a cloud service.

Registration in the Service Collection

Registration happens on IServiceCollection inside Program.cs:

Service registration
var builder = Host.CreateApplicationBuilder(args);
 
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
 
var host = builder.Build();

builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>() tells the container: when IEmailSender is requested, return SmtpEmailSender. builder.Build() converts the collection into a ready-to-use container.

Service Lifetimes

Transient, Scoped, and Singleton

The lifetime determines how long an instance lives:

  • Transient: a new instance every time it is requested.
  • Scoped: one instance per scope (usually one HTTP request).
  • Singleton: one instance for the entire application process.
Choosing a lifetime
builder.Services.AddTransient<IPesanan, PesananService>();
builder.Services.AddScoped<IKontek, KontekService>();
builder.Services.AddSingleton<ILog, LogService>();

AddTransient, AddScoped, and AddSingleton are the three main registration methods. The golden rule: scoped must not be injected into singleton — because the singleton outlives the scope, a reference leak will corrupt behavior and memory.

Choosing the Right Lifetime

A guide:

  • Transient: stateless lightweight services, such as helpers and validation.
  • Scoped: per-request state, such as DbContext and unit of work.
  • Singleton: safe global state, caches, and configuration.

Service Registration Patterns

Constructor Injection

Services are consumed through the constructor — the container fills them automatically:

Constructor injection
public class OrderService
{
    private readonly IEmailSender _sender;
 
    public OrderService(IEmailSender sender)
    {
        _sender = sender;
    }
 
    public async Task PesanAsync(string email)
    {
        await _sender.KirimAsync(email, "Pesanan diterima");
    }
}

OrderService requests IEmailSender through its constructor. The container detects this parameter and injects the matching instance. Constructor injection is the most common and most easily testable form of DI.

Factories and Conditional Registration

For choosing an implementation at runtime, use a factory:

Factory registration
builder.Services.AddSingleton<IEmailSender>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    return config["Mail:Provider"] == "SendGrid"
        ? new SendGridEmailSender()
        : new SmtpEmailSender();
});

The factory receives IServiceProvider (sp) so it can read other services — here IConfiguration — before deciding on the implementation. This pattern is useful for different providers across environments.

Configuring Services in the Generic Host

AddSingleton versus AddScoped in the Host

All host services — logging, configuration, and custom ones — are registered through builder.Services:

Configuring host services
using var host = Host.CreateApplicationBuilder(args)
    .Build();
 
var sender = host.Services.GetRequiredService<IEmailSender>();
await sender.KirimAsync("admin@example.com", "Test");

host.Services.GetRequiredService<IEmailSender>() retrieves a service directly from the container — useful at the application entry point. For ordinary code, rely on constructor injection; accessing the container directly should be kept to a minimum.

Validating Registration in Development

Enable scope validation to catch wrong registrations earlier:

Scope validation
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddScoped<IData, DataService>();
builder.Services.AddSingleton<ICache, CacheService>();

AddScoped on a service that depends on an AddSingleton is valid; conversely, a singleton depending on a scoped service will trigger an error when scope validation is active. Get used to running your application in development with this validation so lifetime problems are caught before production.

Warning

Watch the lifetime when injecting DbContext. Its default is scoped so that one request uses one context — storing it in a singleton will leak connections and state across requests.

DI Practice Summary

  • Always register through interfaces, not concrete classes directly.
  • Choose the lifetime based on state: transient, scoped, or singleton.
  • Use constructor injection; avoid service locators inside methods.
  • Use factories for implementation choices based on configuration.
  • Enable scope validation in development environments.

Closing

Key takeaways:

  • DI separates contracts from implementations and makes code easy to test.
  • Registration happens through builder.Services with AddTransient, AddScoped, and AddSingleton.
  • Transient is always new, scoped is per request, singleton is one per process.
  • Scoped must not be injected into singleton.
  • Constructor injection is the most recommended form of DI.
  • Factories provide implementation choices based on configuration.

In the next episode 9 we will discuss data access and persistence — Entity Framework Core, DbContext, entity mapping, migrations, querying with LINQ and raw SQL, and the SQL Server, PostgreSQL, and SQLite database providers.

Learn .NET - Dependency Injection & Service Lifetimes | Learn .NET