The art of letting go: a human guide to dependency injection in C# is about unlearning the instinct to control everything and trusting that the right tools will be handed to you at the right time. This article walks through the philosophy, the challenge, and the practical implementation of DI in modern .NET applications.
Every developer has written code like this at some point: a class that creates its own dependencies, decides its own collaborators, and ultimately becomes a monolith of tightly coupled logic. It feels natural — you need a logger, you create a logger. You need a database connection, you instantiate one right there, inline, with a new keyword and a sense of total control.
The problem is not the new keyword. The problem is the assumption buried inside it: that this class knows best what it needs and how to get it.
When you hardcode dependencies, you make testing nearly impossible. You make swapping implementations a surgical operation. You make your codebase fragile in exactly the places it needs to be flexible — at the seams between components.
Dependency Injection is not a pattern about frameworks or containers. It is a mindset shift: stop building things that know how to acquire what they need, and start building things that simply declare what they need. Let something else — a container, a factory, a test harness — hand it over.
The first step is to stop depending on concrete types and start depending on interfaces. This is the contract your class announces to the world.
public interface ITransactionRepository
{
Task<TransactionStatus> GetStatusAsync(Guid transactionId);
Task<bool> CommitAsync(Guid transactionId);
}Your business logic should never care whether the data comes from SQL Server, an in-memory store, or a mock in a unit test. It only cares about the shape of the data.
Constructor injection is the most explicit and honest form of DI. It makes dependencies visible, non-optional, and easy to reason about.
public class PaymentProcessor
{
private readonly ITransactionRepository _repository;
private readonly ILogger<PaymentProcessor> _logger;
public PaymentProcessor(
ITransactionRepository repository,
ILogger<PaymentProcessor> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> ProcessAsync(Guid transactionId)
{
_logger.LogInformation("Processing transaction {Id}", transactionId);
var status = await _repository.GetStatusAsync(transactionId);
if (status != TransactionStatus.Pending)
{
_logger.LogWarning("Transaction {Id} is not in a processable state.", transactionId);
return false;
}
return await _repository.CommitAsync(transactionId);
}
}Notice what PaymentProcessor does NOT do: it does not create a database connection, it does not know what logging framework is being used, and it does not decide its own lifecycle. It simply does its job.
In .NET, the built-in DI container lives in Microsoft.Extensions.DependencyInjection. You register your dependencies once, at startup, and the container handles resolution everywhere else.
var builder = WebApplication.CreateBuilder(args);
// Register the repository with a scoped lifetime (once per HTTP request)
builder.Services.AddScoped<ITransactionRepository, SqlTransactionRepository>();
// Register the processor — the container will inject the repository automatically
builder.Services.AddScoped<PaymentProcessor>();
var app = builder.Build();Understanding lifetimes is critical:
The true payoff of DI reveals itself at test time. With dependencies injected, you can substitute real implementations with controlled fakes.
public class PaymentProcessorTests
{
[Fact]
public async Task ProcessAsync_ReturnsFalse_WhenTransactionIsNotPending()
{
// Arrange
var mockRepo = new Mock<ITransactionRepository>();
mockRepo
.Setup(r => r.GetStatusAsync(It.IsAny<Guid>()))
.ReturnsAsync(TransactionStatus.Completed);
var logger = NullLogger<PaymentProcessor>.Instance;
var processor = new PaymentProcessor(mockRepo.Object, logger);
// Act
var result = await processor.ProcessAsync(Guid.NewGuid());
// Assert
Assert.False(result);
}
}No database. No configuration files. No environment variables. Just pure logic, tested in isolation.
When this processor is exposed through an API endpoint, the incoming request is expected to carry the following structure. The controller receives it, extracts the transaction ID, and delegates to the injected PaymentProcessor.
{
"transaction_id": "f3a2d1b0-4e5f-6789-abcd-ef0123456789",
"requested_by": "user-service",
"idempotency_key": "pay-20260615-00042",
"metadata": {
"source": "mobile-checkout",
"retry_attempt": 0,
"currency": "EUR"
}
}The idempotency_key is particularly important in financial contexts: it ensures that even if the same request is sent twice due to a network retry, the operation is only executed once on the backend.
Dependency Injection is not about frameworks. It is not about annotations, containers, or the [Inject] attribute. It is about the discipline of building components that declare their needs instead of satisfying them unilaterally.
When you let go of the new keyword — when you stop your classes from acquiring their own dependencies — something interesting happens. Your code becomes easier to read, because the dependencies are explicit. It becomes easier to test, because you can substitute anything. It becomes easier to maintain, because changing an implementation does not ripple through the entire codebase.
The art of letting go, in software, is the art of building things that trust their environment to provide what they need. That trust, formalized through interfaces and injected through constructors, is what separates code that merely works from code that endures.

SOFTWARE ARCHITECT
Software Architect specialized in fintech, high-availability European systems, and progressive migrations from monoliths to microservices.