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
- The problem: tight coupling and difficulty testing
- Inversion of control as a solution
- Constructor injection versus property or method injection
- Extracting
ILibraryRepositoryfrom Module 5's repositories Libraryinjected withILibraryRepository- DI containers in depth:
AddSingleton/AddScoped/AddTransient - Complete example: registering and resolving in ASP.NET Core
- 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:
Librarybecomes permanently tied to SQLite. Switching to JSON, or to Entity Framework, or to some future third persistence mechanism, requires modifyingLibrarydirectly — 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.
- 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.
- 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.
- Extracting
ILibraryRepository from Module 5's repositories
ILibraryRepository from Module 5's repositoriesModule 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.
Library injected with ILibraryRepository
Library injected with ILibraryRepositoryWith 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.
- DI containers in depth:
AddSingleton/AddScoped/AddTransient
AddSingleton/AddScoped/AddTransientThe 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.
- 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
LibraryasAddSingletonwhen it depends on aScopedLibraryDbContext: 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
ILibraryRepositoryinterface 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
-
Define an
INotifierinterface with a single methodvoid Notify(string message), and two implementations:ConsoleNotifier(usingConsole.WriteLine) andSilentNotifier(which does nothing). ModifyLibraryso it receives anINotifierby constructor and uses it insideRegisterLoaninstead of callingConsole.WriteLinedirectly. -
In the ASP.NET Core service registration from section 7, explain why
ILibraryRepositoryandLibrarymust be registered asAddScoped(and notAddSingleton) while they depend, directly or indirectly, onLibraryDbContext.
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.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
