Arquitectura de Software

The art of letting go: a human guide to dependency injection

Published on 2026-06-14•5 min•0 views

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.

The Challenge

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.

Technical Implementation

Defining the Abstraction

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.

C# Debug Console
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.

Injecting Through the Constructor

Constructor injection is the most explicit and honest form of DI. It makes dependencies visible, non-optional, and easy to reason about.

C# Debug Console
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.

Registering Dependencies in the Container

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.

C# Debug Console
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:

  • •Transient — a new instance every time it is requested. Good for lightweight, stateless services.
  • •Scoped — one instance per request (or scope). The right choice for most database-backed services.
  • •Singleton — one instance for the entire application lifetime. Use carefully; shared state can introduce subtle bugs.

Writing a Testable Unit Test

The true payoff of DI reveals itself at test time. With dependencies injected, you can substitute real implementations with controlled fakes.

C# Debug Console
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.

Example Payload

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.

JSON Response Payload
{
    "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.

Conclusion

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.

Jesús Jahaziel Higareda López

Jesús Jahaziel Higareda López

SOFTWARE ARCHITECT

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

Discussions Arena0

LEAVE A QUESTION

✓ Dispatches to safe localStorage session streams.