Understanding Dependency Injection cover image

Understanding Dependency Injection

By Sofia Reiter · Tuesday, January 20, 2026 · ~4 min read

For weeks, I kept reading that dependency injection is one of the most important concepts in modern .NET development. I nodded along, tried to follow tutorials, and still didn't really get why it mattered. Then one day, while refactoring my Book API, it finally clicked. Let me try to explain it the way I wish someone had explained it to me.

The Problem Without DI

In my first version of the Book API, my controller created everything it needed directly:

public class BooksController : ControllerBase
{
    private readonly BookService _bookService = new BookService(new SqlBookRepository());

    [HttpGet]
    public ActionResult<IEnumerable<Book>> GetAll()
    {
        return Ok(_bookService.GetAll());
    }
}

This looks simple enough, but there are serious problems hiding here. The controller is tightly coupled to BookService and SqlBookRepository. If I want to switch to a different repository for testing, I have to change the controller code. If SqlBookRepository needs a connection string, the controller has to know about that too.

The Core Idea

Dependency injection is really about one principle: a class should receive its dependencies from the outside instead of creating them itself. That's it. Instead of new-ing up objects internally, you declare what you need in the constructor and let something else provide it.

public class BooksController : ControllerBase
{
    private readonly IBookService _bookService;

    public BooksController(IBookService bookService)
    {
        _bookService = bookService;
    }

    [HttpGet]
    public ActionResult<IEnumerable<Book>> GetAll()
    {
        return Ok(_bookService.GetAll());
    }
}

The controller no longer knows or cares which implementation of IBookService it gets. It just knows the interface and trusts that whatever it receives will fulfill that contract.

Registering Services in ASP.NET Core

ASP.NET Core has a built-in DI container. You register your services in Program.cs:

builder.Services.AddScoped<IBookRepository, SqlBookRepository>();
builder.Services.AddScoped<IBookService, BookService>();

When the framework needs to create a BooksController, it sees the constructor requires an IBookService, looks up the registration, creates a BookService, and injects it automatically. If BookService itself requires an IBookRepository, the container resolves that too.

The Three Lifetimes

Understanding service lifetimes was crucial for me:

  • Transient (AddTransient): A new instance is created every time the service is requested. Good for lightweight, stateless services.
  • Scoped (AddScoped): One instance per HTTP request. This is the right choice for most services, especially anything that works with a database context.
  • Singleton (AddSingleton): One instance for the entire application lifetime. Use this for services that are expensive to create and are thread-safe.

Choosing the wrong lifetime can cause subtle bugs. I learned the hard way that injecting a scoped service into a singleton causes the scoped service to behave like a singleton, which can lead to stale data and concurrency issues.

Why It Matters for Testing

This is where the real benefit became clear to me. With DI, I can write unit tests that use a fake implementation:

public class FakeBookRepository : IBookRepository
{
    private readonly List<Book> _books = new();

    public IEnumerable<Book> GetAll() => _books;

    public void Add(Book book) => _books.Add(book);
}

[Fact]
public void GetAll_ReturnsAllBooks()
{
    var repository = new FakeBookRepository();
    repository.Add(new Book { Title = "Test Book", Author = "Test Author" });
    var service = new BookService(repository);
    var controller = new BooksController(service);

    var result = controller.GetAll();

    // Assert the result contains our test book
}

No database, no network calls, no external dependencies. The test runs in milliseconds and is completely predictable. Before DI, testing meant either hitting a real database or doing awkward workarounds.

The Moment It Clicked

I was trying to add logging to my BookService. Without DI, I would have had to modify every place that creates a BookService to also pass in a logger. With DI, I just added ILogger<BookService> to the constructor, and the framework handled the rest. Zero changes in any consuming code.

That was the moment I truly understood. DI isn't just a pattern for testing. It's a way of structuring code so that adding, changing, or replacing any component doesn't ripple through the entire application.

Advice for Other Beginners

If DI feels confusing, start by focusing on the constructor injection pattern. Don't worry about containers or lifetimes initially. Just practice writing classes that receive their dependencies through the constructor instead of creating them with new. Once that feels natural, the rest falls into place.

Dependency injection is one of those concepts that seems unnecessarily complicated until it saves you hours of refactoring. Trust the process.


Comments (3)

David Thursday, February 26, 2026 5:08 PM

Could you elaborate on this topic in a follow-up post?

Greta Saturday, March 7, 2026 8:08 PM

I agree, great article!

Hannah Saturday, March 7, 2026 11:08 PM

Exactly! I had the same thought.

Elena Friday, February 27, 2026 5:08 PM

This is exactly what I was looking for, thank you!

Felix Saturday, February 28, 2026 5:08 PM

I have a question: does this also apply to older versions?

Leave a Comment

4

min read