Across three different lessons in Module 7 — ASP.NET Core, Blazor, and MAUI — the same message came up in passing: "this is dependency injection, it will be studied in depth in Module 8." That moment has arrived. This lesson first explains the underlying problem that inversion of control solves (coupling code to concrete implementations makes it harder to change and, above all, harder to test), then compares the different ways of injecting a dependency, and finally applies all of that to BiblioTech's most concrete case: extracting an ILibraryRepository interface from the four persistence forms built in Module 5 (text, JSON, SQLite, Entity Framework Core), so that Library can use any of them without knowing which one is in play at any given moment. Along the way, it explains in detail the ASP.NET Core services container you already used, without going deep, in the Minimal APIs lesson.

Contents

  1. The problem: tight coupling and difficulty testing
  2. Inversion of control as a solution
  3. Constructor injection versus property or method injection
  4. Extracting ILibraryRepository from Module 5's repositories
  5. Library injected with ILibraryRepository
  6. DI containers in depth: AddSingleton/AddScoped/AddTransient
  7. Complete example: registering and resolving in ASP.NET Core

  1. The problem: tight coupling and difficulty testing

Imagine Library saves and loads its catalog by calling directly into the SQLite persistence methods built in Module 5:

class Library
{
    public List<LibraryItem> Catalog { get; } = new List<LibraryItem>();

    public void SaveCatalog()
    {
        // Library "knows" that persistence is SQLite, with concrete connection details
        using SqliteConnection connection = new SqliteConnection("Data Source=bibliotech.db");
        connection.Open();
        // ... INSERT/UPDATE SQL, one per Book in the catalog ...
    }
}

This design has two serious problems, even if the code "works":

  • Tight coupling: Library becomes permanently tied to SQLite. Switching to JSON, or to Entity Framework, or to some future third persistence mechanism, requires modifying Library directly — a class that, in principle, should only be concerned with loan and catalog logic, not the details of how data is saved.
  • Difficulty testing: writing a unit test (Lesson 4 of this module) for Library's logic would require a real SQLite database available, with known data, every time the tests run. That makes tests slow, fragile (they depend on an external file), and hard to run on any machine with no prior setup.

  1. Inversion of control as a solution

Inversion of control (IoC) flips who decides which concrete implementation gets used: instead of Library directly creating its own persistence dependency (new SqliteConnection(...)), it receives one already built from outside, through an abstraction (an interface) that reveals nothing about the concrete implementation's details:

flowchart LR
    subgraph "Without inversion of control"
        B1["Library"] -->|"new SqliteConnection(...)"| S1["Concrete SQLite"]
    end
    subgraph "With inversion of control"
        B2["Library"] -->|"depends on"| I["ILibraryRepository (interface)"]
        S2["SqliteRepository"] -.->|"implements"| I
        S3["JsonRepository"] -.->|"implements"| I
        Ext["External code (Program.cs, a DI container...)"] -->|"decides which one to create and injects it"| B2
    end

Library stops deciding "how" the data gets persisted: it only declares that it needs something that fulfills the ILibraryRepository contract, and whoever constructs it decides which concrete implementation to hand over. Dependency injection is precisely the technique by which that dependency "enters" from outside — by constructor, property, or method, as the next section covers — instead of being created internally with new. It's the most common way of applying the inversion of control principle.

  1. Constructor injection versus property or method injection

There are three ways to inject a dependency into a class:

Form How it's declared When the dependency is resolved
By constructor A constructor parameter, assigned to a read-only field or property When the object is created; it can never be left half-configured
By property A public property with a set, assigned after the object is created Any time after construction; the object can exist without the dependency assigned yet
By method A parameter of a specific method, rather than a class member Only at the moment that specific method is called
// Constructor injection (the recommended default)
class Library
{
    private readonly ILibraryRepository _repository;

    public Library(ILibraryRepository repository)
    {
        _repository = repository; // mandatory: a Library can't be created without it
    }
}

// Property injection
class Library
{
    public ILibraryRepository? Repository { get; set; } // optional: may be left unassigned

    public void SaveCatalog()
    {
        Repository?.Save(Catalog); // must check it isn't null before using it
    }

    public List<LibraryItem> Catalog { get; } = new List<LibraryItem>();
}

// Method injection
class ItemImporter
{
    public void ImportFrom(ILibraryRepository repository, Library library)
    {
        library.Catalog.AddRange(repository.LoadCatalog()); // only valid for the duration of this call
    }
}

Constructor injection is the recommended default practice in the vast majority of cases, for two reasons: it makes the dependency mandatory (you can't create a Library without supplying its repository, which eliminates at the root any NullReferenceException caused by an oversight), and it makes explicit, just by looking at the constructor's signature, exactly what the class depends on. Property injection is reserved for genuinely optional dependencies (for example, a logging service that can be missing without the object ceasing to function); method injection, for dependencies only needed during a one-off operation, not for the object's whole lifetime.

  1. Extracting ILibraryRepository from Module 5's repositories

Module 5 built four different ways to persist BiblioTech's catalog: plain text (SaveCatalogText/LoadCatalogText), JSON (SaveJsonState/LoadJsonState), SQLite with ADO.NET (SaveBooksToSqlite/LoadBooksFromSqlite), and Entity Framework Core (LibraryDbContext). All four solve, deep down, the same question: "save this catalog" and "give me back the saved catalog." That's the exact signal that a common interface is needed:

interface ILibraryRepository
{
    void SaveCatalog(List<LibraryItem> catalog);
    List<LibraryItem> LoadCatalog();
}

Each mechanism from Module 5 becomes an independent class implementing this interface, reusing the logic already written back then:

class TextRepository : ILibraryRepository
{
    private readonly string _path;

    public TextRepository(string path) => _path = path;

    public void SaveCatalog(List<LibraryItem> catalog)
    {
        using StreamWriter writer = new StreamWriter(_path); // Module 5, SaveCatalogText
        foreach (LibraryItem item in catalog)
        {
            writer.WriteLine($"{item.Title}|{item.Author}|{item.Available}");
        }
    }

    public List<LibraryItem> LoadCatalog()
    {
        // same logic as Module 5's LoadCatalogText, adapted to return the list
        // instead of filling Library.Catalog directly
        List<LibraryItem> catalog = new List<LibraryItem>();
        // ... line-by-line reading with StreamReader, reconstructing each Book ...
        return catalog;
    }
}

class JsonRepository : ILibraryRepository
{
    private readonly string _path;

    public JsonRepository(string path) => _path = path;

    public void SaveCatalog(List<LibraryItem> catalog)
    {
        string json = JsonSerializer.Serialize(catalog); // Module 5, SaveJsonState
        File.WriteAllText(_path, json);
    }

    public List<LibraryItem> LoadCatalog()
    {
        string json = File.ReadAllText(_path);
        return JsonSerializer.Deserialize<List<LibraryItem>>(json) ?? new List<LibraryItem>();
    }
}

class SqliteRepository : ILibraryRepository
{
    private readonly string _connectionString;

    public SqliteRepository(string connectionString) => _connectionString = connectionString;

    public void SaveCatalog(List<LibraryItem> catalog)
    {
        // same logic as Module 5's SaveBooksToSqlite, with SqliteConnection/SqliteCommand
    }

    public List<LibraryItem> LoadCatalog()
    {
        // same logic as Module 5's LoadBooksFromSqlite, with SqliteDataReader
        return new List<LibraryItem>();
    }
}

class EntityFrameworkRepository : ILibraryRepository
{
    private readonly LibraryDbContext _context;

    public EntityFrameworkRepository(LibraryDbContext context) => _context = context;

    public void SaveCatalog(List<LibraryItem> catalog)
    {
        _context.AddRange(catalog.OfType<Book>()); // simplified: EF Core distinguishes Book/Magazine by type
        _context.SaveChanges(); // synchronous version of SaveChangesAsync, Module 5
    }

    public List<LibraryItem> LoadCatalog()
    {
        return _context.Books.Cast<LibraryItem>().ToList(); // LINQ query, Module 5
    }
}

None of these four classes changes the persistence logic you already built in Module 5 — it only reorganizes it behind a common contract. EntityFrameworkRepository, in particular, receives its own LibraryDbContext also through constructor injection: dependency injection applies in a chain, not just at the outermost point.

  1. Library injected with ILibraryRepository

With the interface already defined, Library stops knowing any concrete persistence detail:

class Library
{
    private readonly ILibraryRepository _repository;

    public List<LibraryItem> Catalog { get; private set; } = new List<LibraryItem>();

    public Library(ILibraryRepository repository)
    {
        _repository = repository;
    }

    public void SaveCatalog() => _repository.SaveCatalog(Catalog);

    public void LoadCatalog() => Catalog = _repository.LoadCatalog();
}
// With JsonRepository
Library jsonLibrary = new Library(new JsonRepository("catalog.json"));

// Exactly the same Library class, now with SQLite, without changing a single line of Library
Library sqliteLibrary = new Library(new SqliteRepository("Data Source=bibliotech.db"));

Library is identical in both cases: the only thing that changes is which concrete implementation of ILibraryRepository gets handed to it when it's built. This is exactly the goal of inversion of control laid out in section 2, now concretely solved for BiblioTech's own domain. And as anticipated in section 1, this very structure is what will let Lesson 4 replace ILibraryRepository with a test double (mock) with no file or real database needed during tests.

  1. DI containers in depth: AddSingleton/AddScoped/AddTransient

The ASP.NET Core lesson (Module 7) introduced the services container in a basic way, with builder.Services.AddSingleton<Library>(). Now that you know the full theory of dependency injection, it's time to understand why there are three different registration methods, not just AddSingleton:

Method How many instances it creates When to use it
AddSingleton<T>() A single instance, shared for the entire lifetime of the application Truly global state that's safe to share across concurrent requests (for example, the previous lesson's IPenaltyPolicy, if it holds no state of its own)
AddScoped<T>() A new instance per HTTP request (or "scope"), shared within that same request Dependencies with state of their own tied to one operation, like LibraryDbContext (recall the common mistake from the ASP.NET Core lesson: never as a singleton)
AddTransient<T>() A new instance every time it's requested, even several times within the same request Lightweight, stateless dependencies that it doesn't matter to create repeatedly
var builder = WebApplication.CreateBuilder(args);

// ILibraryRepository resolved as an instance of EntityFrameworkRepository
builder.Services.AddScoped<ILibraryRepository, EntityFrameworkRepository>();
builder.Services.AddScoped<LibraryDbContext>(); // a new instance per request, correct (Module 7)
builder.Services.AddScoped<Library>(); // Library now also depends on ILibraryRepository

var app = builder.Build();

app.MapGet("/books", (Library library) =>
{
    library.LoadCatalog();
    return Results.Ok(library.Catalog);
});

AddScoped<ILibraryRepository, EntityFrameworkRepository>() registers two types: the first is the type that will be requested (the interface), the second is the concrete implementation the container must build when someone asks for that type. When the endpoint declares Library library as a parameter, ASP.NET Core automatically resolves the whole chain: it builds an EntityFrameworkRepository, which in turn needs a LibraryDbContext (also registered), and with that repository already built, it finally builds the Library the endpoint receives. None of these intermediate constructions is written by hand by the programmer: the DI container resolves it by reading each registered class's constructors.

AddScoped for Library (instead of AddSingleton, as seen in simplified form in Module 7) is now the correct choice: since Library depends on a LibraryDbContext — which you already know must live only for the duration of a request — Library itself must share the same lifetime as its dependency, or it would drag a stale DbContext across different requests.

  1. Complete example: registering and resolving in ASP.NET Core

Putting all the previous pieces together, here's the complete service registration for a BiblioTech API that uses Entity Framework Core as its persistence mechanism, with Library injected with ILibraryRepository instead of coupled directly to EF Core:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<LibraryDbContext>(options =>
    options.UseSqlite("Data Source=bibliotech.db")); // AddDbContext already registers the correct lifetime (Scoped)

builder.Services.AddScoped<ILibraryRepository, EntityFrameworkRepository>();
builder.Services.AddScoped<Library>();

var app = builder.Build();

app.MapPost("/catalog/save", (Library library) =>
{
    library.SaveCatalog();
    return Results.Ok("Catalog saved.");
});

app.Run();

To test with a different persistence mechanism — for example, in a manual testing environment with no database — it would be enough to change a single line:

// Instead of EF Core, use JSON: neither Library nor the endpoints need any change
builder.Services.AddSingleton<ILibraryRepository>(new JsonRepository("catalog.json"));

This is the practical result of having inverted control since section 2: completely changing the whole API's persistence mechanism is now a single line in Program.cs, instead of a change scattered throughout Library.

Common Mistakes and Tips

  • Registering Library as AddSingleton when it depends on a Scoped LibraryDbContext: the ASP.NET Core container detects this and throws an exception at startup (a captive dependency, a short-lived dependency "trapped" inside a longer-lived object). A class's lifetime should never be longer than that of its dependencies.
  • Injecting by property "for convenience" instead of by constructor: makes the dependency look optional when it's really mandatory, and allows the object to be created in an incomplete state (with the dependency unassigned) that only fails later, when it's used.
  • Confusing the ILibraryRepository interface with "just one more useless layer": it looks redundant while there's only one real implementation, but its value shows up exactly when you need to switch implementations (section 7) or replace it with a test double (Lesson 4).
  • Tip: when designing a class and asking yourself "should this be new'd right here, or received from outside?", the golden question is: will I ever need to replace this dependency with another implementation, or with a test version? If the answer is yes, inject it.

Exercises

  1. Define an INotifier interface with a single method void Notify(string message), and two implementations: ConsoleNotifier (using Console.WriteLine) and SilentNotifier (which does nothing). Modify Library so it receives an INotifier by constructor and uses it inside RegisterLoan instead of calling Console.WriteLine directly.

  2. In the ASP.NET Core service registration from section 7, explain why ILibraryRepository and Library must be registered as AddScoped (and not AddSingleton) while they depend, directly or indirectly, on LibraryDbContext.

Solutions

interface INotifier
{
    void Notify(string message);
}

class ConsoleNotifier : INotifier
{
    public void Notify(string message) => Console.WriteLine(message);
}

class SilentNotifier : INotifier
{
    public void Notify(string message) { /* does nothing */ }
}

class Library
{
    private readonly ILibraryRepository _repository;
    private readonly INotifier _notifier;

    public List<Loan> Loans { get; } = new List<Loan>();

    public Library(ILibraryRepository repository, INotifier notifier)
    {
        _repository = repository;
        _notifier = notifier;
    }

    public void RegisterLoan(Loan loan)
    {
        Loans.Add(loan);
        _notifier.Notify($"Loan of '{loan.Book.Title}' registered.");
    }
}

LibraryDbContext is registered with AddDbContext (equivalent to AddScoped) because it isn't designed to be shared across concurrent requests (ASP.NET Core lesson, Module 7). If ILibraryRepository (which wraps that DbContext) or Library (which depends on ILibraryRepository) were registered as AddSingleton, the first HTTP request would leave a LibraryDbContext "trapped" inside that singleton that should have been discarded once that request ended, and subsequent requests would reuse a stale or already-disposed context, causing runtime errors.

Conclusion

In this lesson you've understood the underlying problem inversion of control solves (tight coupling and difficulty testing), compared constructor injection — the recommended default — with property and method injection, extracted an ILibraryRepository interface that unifies Module 5's four persistence forms behind a common contract, and injected that interface into Library to decouple it from any concrete mechanism. You've also finally understood in depth the difference between AddSingleton, AddScoped, and AddTransient in ASP.NET Core's services container, which had only been mentioned in passing in Module 7.

All this work decoupling Library from its concrete dependencies has a benefit you haven't exploited yet: it's now possible to replace ILibraryRepository with a test version that touches no real file or database. That's precisely the doorway into the next lesson: unit testing, where ILibraryRepository will be replaced with a test double (mock) to verify Library's logic quickly, in isolation, and repeatably.

© Copyright 2026. All rights reserved