Dependency Injection in .NET
.NET has a built-in Dependency Injection container — we don't need external libraries. DI inverts dependencies: a class declares what it needs, and the container supplies it. The result: testable, loosely coupled, maintainable code.
Constructor injection
The standard is constructor injection. Dependencies are explicit and immutable.
public sealed class ProbeService : IProbeService
{
private readonly IProbeRepository _repository;
private readonly ILogger<ProbeService> _logger;
public ProbeService(IProbeRepository repository, ILogger<ProbeService> logger)
{
_repository = repository;
_logger = logger;
}
}
ProfessNet standard: we inject dependencies through the constructor and keep them in
readonlyfields. We do not use aservice locator(manualprovider.GetService<T>()) in business logic.
Registering services
We register services in Program.cs, programming to an interface.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProbeService, ProbeService>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddTransient<IReportBuilder, ReportBuilder>();
builder.Services.AddHttpClient<IBackendClient, BackendClient>();
var app = builder.Build();
Lifetimes — choose deliberately
| Lifetime | One instance per | For what |
|---|---|---|
Singleton | the whole application | stateless services, cache, configuration |
Scoped | a single HTTP request | domain services, DbContext |
Transient | each resolution | lightweight, stateless helpers |
ProfessNet standard: services that depend on the database are
Scoped(likeDbContext). The most common mistake is injecting aScopedservice into aSingleton— the container catches this at startup (ValidateScopes), so do not ignore that exception.
Captive dependency — a trap
A Singleton holding a Scoped service "captures" that dependency for the
entire lifetime of the application.
// bad — Singleton holds a Scoped DbContext
public sealed class CacheService // registered as Singleton
{
public CacheService(AppDbContext db) { } // DbContext is Scoped!
}
The fix: in the Singleton, obtain the Scoped service via
IServiceScopeFactory.
public CacheService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task RefreshAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// ...
}
DI and testability
Thanks to interfaces, in tests we substitute a mock for the real implementation.
var repo = new Mock<IProbeRepository>();
repo.Setup(r => r.GetHostsAsync("corp", default))
.ReturnsAsync(new[] { "dc01" });
var service = new ProbeService(repo.Object, NullLogger<ProbeService>.Instance);
Tip: register
ILogger<T>automatically (AddLogging), and in tests useNullLogger<T>.Instance— there's no need to mock the logger.
Constructor injection, programming to an interface, a deliberate choice of lifetime. The built-in .NET container is entirely sufficient, and the code gains testability and clean boundaries between layers.