.NET Academy

Dependency injection

Let classes receive their dependencies instead of creating them — for flexible, testable code.

14 min read

You will learn

  • Explain the problem DI solves
  • Refactor a class to use constructor injection with interfaces
  • Register services with the right lifetime (singleton, scoped, transient)
  • Avoid the captive dependency pitfall

The problem: classes that build their own world

public class OrderService
{
    private readonly SqlOrderRepository repository = new SqlOrderRepository("Server=prod;...");
    private readonly SmtpEmailSender email = new SmtpEmailSender("smtp.example.com");
 
    public void Place(Order order)
    {
        repository.Save(order);
        email.Send(order.CustomerEmail, $"Order {order.Id} confirmed");
    }
}

This works, but:

  • You can't test OrderService without a real database and mail server.
  • You can't swap SMTP for SendGrid without editing OrderService.
  • Configuration (connection strings) is buried inside the class.

The fix: ask for dependencies

Dependency injection (DI) means a class receives what it needs — usually through its constructor — instead of creating it:

public class OrderService(IOrderRepository repository, IEmailSender email)
{
    public void Place(Order order)
    {
        repository.Save(order);
        email.Send(order.CustomerEmail, $"Order {order.Id} confirmed");
    }
}

OrderService now depends only on interfaces. Production passes the real implementations; tests pass fakes. This is the dependency inversion principle — depend on abstractions, not concrete details (Track 12).

The container wires it up

Creating object graphs by hand gets tedious (new OrderService(new SqlOrderRepository(new DbContext(...)), new SmtpEmailSender(...))). A DI container does it for you. In ASP.NET Core, you register services at startup:

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddDbContext<ShopDb>(o => o.UseSqlServer(connectionString));   // registered as scoped

…and the framework resolves them: when an endpoint or controller needs an OrderService, the container sees its constructor wants an IOrderRepository and an IEmailSender, creates those (and their dependencies), and passes them in.

app.MapPost("/orders", (Order order, OrderService orders) =>   // injected automatically
{
    orders.Place(order);
    return Results.Created($"/orders/{order.Id}", order);
});

Lifetimes

LifetimeInstancesTypical use
SingletonOne for the whole appStateless, thread-safe services; caches; HttpClient factories; configuration
ScopedOne per scope — in ASP.NET Core, per HTTP requestDbContext, unit-of-work, per-request user context
TransientA new one every time it's resolvedLightweight, stateless helpers

The container also disposes the IDisposable services it created, at the end of their lifetime.

Captive dependencies

A service must not depend on something with a shorter lifetime. A singleton that receives a scoped DbContext keeps that one context alive for the app's whole life — shared by every request and thread. ASP.NET Core's scope validation throws in Development to catch this. If a singleton truly needs scoped services, inject IServiceScopeFactory and create a scope when needed.

How a container works

There's no magic — at heart, a container is a dictionary from types to factory functions. Here's a toy one:

Program.cs
Loading editor…

C# runtime not loaded

Real containers add lifetimes, disposal, open generics and constructor discovery via reflection — but the idea is the same.

Good DI habits

  • Prefer constructor injection; avoid calling serviceProvider.GetService<T>() all over the code (the service locator anti-pattern).
  • Keep constructors simple — just store dependencies.
  • Many constructor parameters (say, more than 5) suggest a class doing too much.
  • Wrap time, randomness, file system and network behind interfaces (TimeProvider is built in since .NET 8) so they can be faked in tests.

Key takeaways

  • DI: classes receive their dependencies (usually interfaces) instead of creating them.
  • Register services in builder.Services with a lifetime; the container resolves whole object graphs.
  • Singleton = one per app, scoped = one per request, transient = new each time.
  • Never let a long-lived service capture a shorter-lived one.

Exercise

WelcomeService creates its own dependencies, so it can't be tested. Refactor it to receive an IClock and an IEmailSender through its constructor. Greet returns "Good morning, <name>!" before 12:00, "Good afternoon, <name>!" before 18:00 and "Good evening, <name>!" otherwise. SendWelcome emails the greeting. FakeClock and FakeEmailSender are used by the checks — don't change them.

Program.cs
Loading editor…

C# runtime not loaded

Hint 1

Add a constructor: public WelcomeService(IClock clock, IEmailSender email) and store both in private readonly fields (or use a primary constructor: class WelcomeService(IClock clock, IEmailSender email)).

Hint 2

Replace DateTime.Now with clock.Now and new ConsoleEmailSender() with the injected sender.

Hint 3

Update the first line of the program too: new WelcomeService(new SystemClock(), new ConsoleEmailSender()).

Show solution
var service = new WelcomeService(new SystemClock(), new ConsoleEmailSender());
Console.WriteLine(service.Greet("Ada"));

interface IClock { DateTime Now { get; } }
interface IEmailSender { void Send(string to, string body); }

class SystemClock : IClock { public DateTime Now => DateTime.Now; }
class ConsoleEmailSender : IEmailSender
{
    public void Send(string to, string body) => Console.WriteLine($"Email to {to}: {body}");
}

// Used by the checks — don't change these two.
class FakeClock : IClock { public DateTime Now { get; set; } }
class FakeEmailSender : IEmailSender
{
    public List<string> Sent { get; } = new();
    public void Send(string to, string body) => Sent.Add($"{to}: {body}");
}

class WelcomeService(IClock clock, IEmailSender email)
{
    public string Greet(string name) => clock.Now.Hour switch
    {
        < 12 => $"Good morning, {name}!",
        < 18 => $"Good afternoon, {name}!",
        _ => $"Good evening, {name}!",
    };

    public void SendWelcome(string address, string name) => email.Send(address, Greet(name));
}

Quiz

4 questions · 3 correct to pass
  1. 1.What's the main benefit of injecting an IEmailSender instead of writing `new SmtpEmailSender()` inside a class?

  2. 2.In ASP.NET Core, how long does a Scoped service live?

  3. 3.Which lifetime fits a stateless, thread-safe service like a pricing calculator with no dependencies on scoped services?

  4. 4.What's a captive dependency?

To complete this lesson

  • ○ Solve the exercise
  • ○ Pass the quiz (3/4 correct)