Understanding Dependency Injection

Understanding Dependency Injection

Sofia Reiter ยท January 20, 2026
C#Dependency InjectionDesign Patterns
LearningTutorials

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 Feb 26, 2026 · 17:08

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

Greta Mar 07, 2026 · 20:08

I agree, great article!

Hannah Mar 07, 2026 · 23:08

Exactly! I had the same thought.

Elena Feb 27, 2026 · 17:08

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

Felix Feb 28, 2026 · 17:08

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


Leave a comment

Estimated Read Time
4 minutes

725 words

An unhandled error has occurred. Reload ๐Ÿ—™

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.