Dependency Injection Concepts
By the end of this lesson
Explain why a class should receive its dependencies rather than construct them.
Start with the idea, because the term gets in the way. A class needs things to do its job: somewhere to save an order, something to send an email, a clock to read the time. It can either create those things itself, or be handed them.
Being handed them is the whole idea. The class states what it needs in its constructor and someone else decides what to supply. That is all dependency injection means.
The word injection describes the handing over. The word dependency describes the thing being handed. Both are more intimidating than the concept, which is closer to a recipe listing its ingredients than going shopping.
// Version 1: constructs what it needs. Works, and cannot be tested.
public class OrderServiceA
{
public string Confirm(int orderId)
{
SqlOrderStore store = new("Server=live-db;Database=Sales;...");
SmtpEmailSender email = new("smtp.supplier.example", 587);
Order? order = store.Find(orderId);
if (order is null)
{
return "Not found.";
}
order.ConfirmedAt = DateTime.Now;
store.Save(order);
email.SendConfirmation(order);
return "Confirmed.";
}
}
// Version 2: states what it needs and is given it.
public class OrderServiceB
{
private readonly IOrderStore _store;
private readonly IEmailSender _email;
private readonly TimeProvider _time;
public OrderServiceB(IOrderStore store, IEmailSender email, TimeProvider time)
{
_store = store;
_email = email;
_time = time;
}
public string Confirm(int orderId)
{
Order? order = _store.Find(orderId);
if (order is null)
{
return "Not found.";
}
order.ConfirmedAt = _time.GetUtcNow().UtcDateTime;
_store.Save(order);
_email.SendConfirmation(order);
return "Confirmed.";
}
}- Version 1 names three concrete things it cannot do without: a SQL connection string, an SMTP server, and the machine clock. Calling Confirm on it requires a reachable database and mail server. There is no way to test it without them, because there is no seam — no point at which you could put something else in.
- It has a second problem that shows up later. The connection string is buried inside a method, so configuration is now spread across every class that happens to need data.
- Version 2 names three abstractions in its constructor and stores them in readonly fields. The class no longer knows or cares whether the store talks to SQL Server, an in-memory dictionary, or a test double that records what it was asked to save.
- The interfaces are what create the seam. Depending on IOrderStore rather than SqlOrderStore is the part that makes substitution possible; the constructor parameter is just how the substitute arrives.
- The clock is included deliberately, because it is the dependency people forget. DateTime.Now is a hidden dependency on the machine, and it makes "confirmed yesterday" impossible to test. TimeProvider is the built-in abstraction for this, and it comes with a fake for tests.
- Nothing about version 2 requires a framework or a container. It is a constructor. You could construct it by hand in three lines, and in a test that is exactly what you do.
When you hand the wiring to a container, you also choose how long each service lives. There are three lifetimes:
- Transient
- A new instance every time one is asked for. Two classes that both need it get two different instances. The safe default for small, stateless services: nothing is shared, so nothing can be shared wrongly. The cost is allocation, which matters only if the object is expensive to create.
- Scoped
- One instance per scope. In a web application a scope is a request, so every class involved in handling one request shares the same instance, and the next request gets a fresh one. This is what a DbContext is registered as, which is how several services in one request take part in the same unit of work.
- Singleton
- One instance for the whole application, created on first use and shared by everything afterwards. Right for things that are genuinely global and expensive: a cache, configuration, a client that pools connections. It carries two obligations — it must be thread-safe, because many requests will use it at once, and it must not hold anything belonging to a single request.
var builder = WebApplication.CreateBuilder(args);
// "When something asks for IOrderStore, give it SqlOrderStore."
builder.Services.AddScoped<IOrderStore, SqlOrderStore>();
builder.Services.AddScoped<OrderServiceB>();
// Shared for the whole application. Must be thread-safe.
builder.Services.AddSingleton<IPriceCache, InMemoryPriceCache>();
builder.Services.AddSingleton(TimeProvider.System);
// Cheap and stateless, so a fresh one each time costs nothing.
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
var app = builder.Build();
app.MapPost("/orders/{orderId:int}/confirm",
(int orderId, OrderServiceB service) => service.Confirm(orderId));
app.Run();- Each registration is a rule: when a constructor asks for this type, provide that implementation. Nothing is created at this point.
- OrderServiceB is registered without an interface, which is fine. Registering a concrete class is common for application services that nothing needs to substitute.
- When a request arrives, the container reads OrderServiceB's constructor, sees it needs IOrderStore, IEmailSender and TimeProvider, resolves each one according to its lifetime, and constructs the service. This discovery is reflection, which is the previous lesson's subject appearing in something you use daily.
- The endpoint's second parameter is the service. ASP.NET Core recognises that it is not part of the route and resolves it from the container, so nothing in the handler constructs anything.
- AddSingleton(TimeProvider.System) registers an instance you already have rather than a type to construct. In a test you would register a fake time provider here instead, and every service that reads the clock would see the controlled time.
- Compare this to version 1. The connection string, the SMTP host and the choice of implementation are all decided here, in one file, next to each other — and changing any of them touches no service.
Summary
- A class states what it needs in its constructor and is handed it, rather than constructing it
- The gain is a seam: dependencies can be substituted, so the class can be tested without infrastructure
- Transient is a new instance each time, scoped is one per request, singleton is one for the application
- A singleton holding a scoped dependency captures it — growing memory, stale data, and thread-safety failures
- Inject collaborators, not data, and remember the technique is the constructor rather than the container
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Think about it
An application registers a ReportGenerator as a singleton because creating it involves loading templates from disk. Its constructor takes an IOrderStore, which is registered as scoped and wraps a DbContext.
In development it works. In production, memory grows through the day, and after a few hours reports start showing yesterday's totals alongside intermittent errors about a second operation on the same context. Explain the chain of cause and effect, then give the fix.
Show solution
The singleton captured the scoped service. The container resolved ReportGenerator once, and to do that it resolved one IOrderStore — from whatever scope happened to be active at the time. That instance, and the DbContext inside it, is now held by the singleton for the life of the process.
Memory growth comes from the change tracker. A DbContext remembers every entity it has loaded so it can detect changes. A context intended to live for one request is now loading entities for hours, and none of them are ever released.
Stale totals come from the same place. The context serves entities it has already tracked rather than re-reading them, so data changed by other requests is invisible. This is correct behaviour for a request-length context and wrong for one that never ends.
The intermittent errors come from thread safety. A singleton is used by many requests at once, and a DbContext supports one operation at a time. Two simultaneous reports produce the second-operation error, which explains why it is intermittent and why development never showed it.
The fix keeps the singleton but not the captured context. Inject IServiceScopeFactory, create a scope inside each method that needs data, resolve IOrderStore from that scope, and dispose the scope when the work is done. The templates stay loaded once; the context lives as long as one report.
Two alternatives are worth weighing. Registering ReportGenerator as scoped removes the problem but reloads the templates on every request, which is the cost the singleton existed to avoid. Better is to split the class: a singleton that holds the templates, and a scoped generator that takes both the template store and the order store. That separates the expensive, immutable part from the per-request part, which is the underlying design mistake rather than a workaround for it.
Try it yourself
Try it yourself
Take this class and make it testable: a LateFeeCalculator whose method reads DateTime.Now, calls new ConfigReader().GetDecimal("LateFee.DailyRate") to get a rate, and returns a fee for an invoice.
Then write the test you could not have written before, and say which change made it possible.
Show solution
Two hidden dependencies come out into the constructor: the clock and the configuration. Both were being constructed or read inside the method, which is why the method's result depended on the machine it ran on.
The clock is the change that matters most. DateTime.Now makes "an invoice 30 days overdue" untestable, because you cannot choose today. With TimeProvider injected, a test sets the current time and the calculation becomes deterministic. The built-in FakeTimeProvider in Microsoft.Extensions.TimeProvider.Testing exists for exactly this.
The rate is injected through an abstraction rather than read from a file, so a test can set it to a known value. It is also now a decimal on a strongly typed options object rather than a string lookup, which moves a possible parse failure to start-up instead of the middle of a calculation.
Notice what did not change: the arithmetic. The calculation was never the hard part. What made the class untestable was that it reached out to the world in the middle of the arithmetic, and moving those reaches to the constructor is the entire fix.
The test constructs the calculator directly with no container. That is worth seeing, because it shows the benefit comes from the constructor rather than from any framework — a container is a convenience for production wiring, not the source of testability.
public class LateFeeOptions
{
public decimal DailyRate { get; init; }
}
public class LateFeeCalculator
{
private readonly TimeProvider _time;
private readonly LateFeeOptions _options;
public LateFeeCalculator(TimeProvider time, LateFeeOptions options)
{
_time = time;
_options = options;
}
public decimal FeeFor(Invoice invoice)
{
DateTimeOffset now = _time.GetUtcNow();
if (now <= invoice.DueOn)
{
return 0m;
}
int daysLate = (now.Date - invoice.DueOn.Date).Days;
return decimal.Round(invoice.Total * _options.DailyRate * daysLate, 2);
}
}
// The test that was impossible before.
[Fact]
public void Charges_the_daily_rate_for_each_day_overdue()
{
FakeTimeProvider time = new();
time.SetUtcNow(new DateTimeOffset(2025, 3, 31, 9, 0, 0, TimeSpan.Zero));
LateFeeCalculator calculator = new(time, new LateFeeOptions { DailyRate = 0.01m });
Invoice invoice = new()
{
Total = 1000m,
DueOn = new DateTimeOffset(2025, 3, 21, 9, 0, 0, TimeSpan.Zero),
};
Assert.Equal(100m, calculator.FeeFor(invoice));
}Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.