Dependency injection without the mystery
Give services clear responsibilities and lifetimes, and make your code easier to change and test.
Here's what you'll be able to do.
- Register and consume an application service
- Choose compatible service lifetimes
- Avoid capturing scoped dependencies in singletons
Understand controllers and EF Core. Open Services/BoardSummary.cs and Program.cs.
Understand the idea
Dependency injection moves object construction out of the code that uses the object. Instead of a controller deciding how to build a database service, it declares what it needs. The container assembles those dependencies using registrations in Program.cs.
The useful result is explicit coupling. A controller can depend on a small interface describing its task, while the concrete implementation handles persistence. Interfaces are not mandatory for every class; use them when they create a meaningful boundary.
Choose your environment
Open TaskBoard.csproj from the example download. Install the ASP.NET and web development workload and a Visual Studio release supporting .NET 10. Select the TaskBoard launch profile and press F5. Use Solution Explorer to find the files named below.
Install the .NET 10 SDK and C# Dev Kit. Open the TaskBoard folder in VS Code. Run dotnet restore, then dotnet run in the integrated terminal. Browse http://localhost:5300. Open files with Ctrl+P (Cmd+P on macOS).
Describe a focused responsibility
IBoardSummary answers one question: how many tasks and completed tasks belong to a user? The interface accepts the owner id explicitly and supports cancellation. The Summary record makes the returned shape unambiguous.
BoardSummary receives BoardDb rather than opening a connection itself. It keeps ownership filtering close to the query. It does not read HttpContext, so it can be reused from a controller or a test without inventing a browser request.
using Microsoft.EntityFrameworkCore;
using TaskBoard.Data;
namespace TaskBoard.Services;
public sealed record Summary(int Total, int Completed);
public interface IBoardSummary
{
Task<Summary> GetAsync(string ownerId, CancellationToken ct);
}
public sealed class BoardSummary(BoardDb db) : IBoardSummary
{
public async Task<Summary> GetAsync(string ownerId, CancellationToken ct)
{
var query = db.Tasks.AsNoTracking().Where(t => t.OwnerId == ownerId);
return new Summary(await query.CountAsync(ct), await query.CountAsync(t => t.IsComplete, ct));
}
}Register the service with the right lifetime
AddScoped creates one instance per request scope in an MVC app. BoardDb is also scoped by AddDbContext, so a scoped summary service can safely depend on it. A singleton would outlive the request and must not capture that context.
Transient creates a new instance each time it is resolved. Singleton shares one instance across the application and must be safe for concurrent use. Lifetime is about state, disposal, and concurrency—not simply which registration makes a startup error disappear.
builder.Services.AddDbContext<BoardDb>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Board")));
builder.Services.AddScoped<IBoardSummary, BoardSummary>();Ask for the dependency in the controller
The constructor declares the service the endpoint uses. ASP.NET resolves it while creating the controller. If a required registration is missing, the exception names the type it could not construct, which is useful evidence.
Keep your composition in Program.cs. Avoid calling BuildServiceProvider inside a registration method to obtain a second container. That can create duplicate singleton instances and confusing disposal behavior.
public class BoardApiController(BoardDb db, IBoardSummary summary) : ControllerBase
{
[HttpGet("summary")]
public async Task<Summary> Summary(CancellationToken ct)
=> await summary.GetAsync(OwnerId, ct);
}Replace the implementation deliberately
A test can supply a fixed implementation without querying a database. That helps isolate controller behavior. Integration tests should still exercise the real implementation against a suitable database because a fake cannot prove SQL translation or relationship behavior.
Choose the testing level based on the risk. A formatting decision may be a unit test. An ownership-filtered query deserves a database-backed test. The interface allows substitution; it does not make every substitution realistic.
public sealed class FixedSummary : IBoardSummary
{
public Task<Summary> GetAsync(string ownerId, CancellationToken ct)
=> Task.FromResult(new Summary(5, 2));
}Make it your own.
Register FixedSummary in a local copy of the app and visit /api/tasks/summary while signed in. Then restore the real registration and compare the values.
See the solution & reasoning
Replace the IBoardSummary registration with AddScoped<IBoardSummary, FixedSummary>() for this experiment. The endpoint should return total 5 and completed 2. Restore BoardSummary afterward. This demonstrates that consumers depend on the contract rather than concrete construction.
When something doesn't work
“Unable to resolve service” usually identifies a missing registration or a constructor dependency whose own dependency is missing. Trace that chain.
“Cannot consume scoped service from singleton” is a lifetime mismatch. Do not hide it by disabling scope validation.
DbContext is not thread-safe. A singleton wrapper does not make concurrent context use safe.
Which lifetime fits a service that directly uses the request-scoped DbContext?
That's another skill in your toolkit.
Your progress is saved in this browser. Sign in to save completion to your account.