Skip to main content

08.1 - Repository Pattern & Dependency Injection

Recap

In 07.1 - EF Core: Relationships, Migrations, Querying you built AppDbContext with the Configuration and SavedGame entities, ran migrations against SQLite and stored a whole GameState in a JSON column. Earlier, in lecture 04.2, the JSON file repositories ConfigRepositoryJson and GameRepositoryJson were written against the IConfigRepository / IGameRepository interfaces. Now the two worlds meet: the same interfaces get an EF Core implementation, and the console app stops caring which one it is talking to.

By the end of this lecture you should be able to:

  • Explain Dependency Inversion, Inversion of Control and Dependency Injection, and how the three relate.
  • Implement IGameRepository on top of AppDbContext so that it behaves exactly like the JSON version.
  • Write one set of xUnit contract tests and run it against both implementations.
  • Register services in Microsoft.Extensions.DependencyInjection with the right lifetime and resolve them in a console app.
  • Switch between JSON and database persistence with one value in appsettings.json.
Demo code

Lecture demos: csharp-2026-fall

The problem: new everywhere

This is what most A3 solutions look like inside ConsoleUI:

public class GameController
{
public void NewGame()
{
var configs = new ConfigRepositoryJson("data/configs");
var config = configs.Get(ChooseConfigName(configs.List()));
// ...
}

public void LoadGame()
{
var games = new GameRepositoryJson("data/games");
var state = games.Get(ChooseGameId(games.List()));
// ...
}
}

It works, and it has three problems that all show up the moment A4 asks you to add a database:

  • ConsoleUI is hard-wired to DAL.Json. Switching to EF Core means editing every method that says new GameRepositoryJson, and the folder name is repeated in each of them.
  • Tests cannot replace the repository with a fake. Every test writes real files.
  • The class that plays the game also decides how games are stored. Two reasons to change, one class.

Three steps: DIP, IoC, DI

Dependency Inversion Principle

Formulated by Robert C. Martin in 1996:

  • High-level modules should not depend on low-level modules. Both should depend on abstractions.
  • Abstractions should not depend on details. Details should depend on abstractions.

The everyday example is the phone charger. Before USB-C every manufacturer had its own plug and every household had a drawer full of them. USB-C is the abstraction: phones and chargers both depend on the standard, not on each other.

In the "After" picture ConsoleUI compiles without knowing that DAL.Json or DAL.EF exist. The arrow from the implementation to the interface points up, towards the abstraction — that is the inversion.

Inversion of Control

DIP says what to depend on. Inversion of Control says who creates it: not the class that uses it. GameController no longer calls new on a repository — somebody outside hands one in. Control over object creation moves out of the class, to the edge of the application.

Dependency Injection

DI is the concrete technique that implements IoC: the dependency is passed in, most often through the constructor.

public class GameController(IGameRepository games, IConfigRepository configs)
{
public void LoadGame()
{
var state = games.Get(ChooseGameId(games.List()));
// ...
}
}

A primary constructor is enough. The class states its needs in its signature, and whoever constructs it must satisfy them. That "whoever" is the topic of the second half of this lecture. Remember the chain: DIP → IoC → DI, principle → pattern → technique.

The repository contract

The interfaces from lecture 04.2 stay exactly as they were. They live in GameEngine, next to GameState and GameConfiguration, because they are part of the domain's vocabulary — both DAL.Json and DAL.EF reference GameEngine, never the other way round.

public interface IConfigRepository
{
List<string> List();
GameConfiguration Get(string name);
void Save(GameConfiguration config);
void Delete(string name);
}

public interface IGameRepository
{
List<(Guid Id, string Name, DateTime SavedAtUtc)> List();
GameState Get(Guid id);
void Save(GameState state);
void Delete(Guid id);
}

An interface alone is not a contract. The behaviour has to be spelled out too, otherwise two implementations drift apart in exactly the corner cases that matter:

MethodAgreed behaviour
List()Newest first for games, alphabetical for configs. Empty list, never null, when nothing is stored.
Get(...)Throws KeyNotFoundException when the key does not exist.
Save(...)Upsert: insert when new, overwrite when the key exists. Never creates duplicates.
Delete(...)Silent no-op when the key does not exist.

JSON implementation recap

One file per game, named after the id; List() enumerates the folder. IncludeFields is there because (int Row, int Col) tuples are fields, not properties, and System.Text.Json skips fields by default.

public class GameRepositoryJson(string directory) : IGameRepository
{
private static readonly JsonSerializerOptions Options = new() { IncludeFields = true };

private string PathFor(Guid id) => Path.Combine(directory, $"{id}.json");

public GameState Get(Guid id)
{
if (!File.Exists(PathFor(id))) throw new KeyNotFoundException($"Game {id} not found");
return JsonSerializer.Deserialize<GameState>(File.ReadAllText(PathFor(id)), Options)
?? throw new InvalidDataException($"Corrupt save file for {id}");
}

public void Save(GameState state)
{
Directory.CreateDirectory(directory);
File.WriteAllText(PathFor(state.Id), JsonSerializer.Serialize(state, Options));
}

public void Delete(Guid id) => File.Delete(PathFor(id)); // no-op when missing
}

EF Core implementation

The entity from lecture 07.1 keeps the whole state as JSON and pulls out only what the list view needs:

public class SavedGame
{
public Guid Id { get; set; }
public required string Name { get; set; }
public DateTime SavedAtUtc { get; set; }
public required string StateJson { get; set; }
}

The repository is a thin mapper between GameState and SavedGame. Note that it receives the AppDbContext — it does not create one:

public class GameRepositoryEf(AppDbContext db) : IGameRepository
{
private static readonly JsonSerializerOptions Options = new() { IncludeFields = true };

public List<(Guid Id, string Name, DateTime SavedAtUtc)> List() =>
db.SavedGames
.AsNoTracking()
.OrderByDescending(g => g.SavedAtUtc)
.Select(g => new { g.Id, g.Name, g.SavedAtUtc })
.AsEnumerable() // SQL ends here, tuples are built in memory
.Select(g => (g.Id, g.Name, g.SavedAtUtc))
.ToList();

public GameState Get(Guid id)
{
var row = db.SavedGames.Find(id)
?? throw new KeyNotFoundException($"Game {id} not found");
return JsonSerializer.Deserialize<GameState>(row.StateJson, Options)
?? throw new InvalidDataException($"Corrupt state for game {id}");
}

public void Save(GameState state)
{
var json = JsonSerializer.Serialize(state, Options);
var row = db.SavedGames.Find(state.Id);
if (row is null)
{
db.SavedGames.Add(new SavedGame
{ Id = state.Id, Name = state.Config.Name, SavedAtUtc = DateTime.UtcNow, StateJson = json });
}
else
{
row.StateJson = json;
row.SavedAtUtc = DateTime.UtcNow;
}
db.SaveChanges();
}

public void Delete(Guid id)
{
if (db.SavedGames.Find(id) is not { } row) return;
db.SavedGames.Remove(row);
db.SaveChanges();
}
}

ConfigRepositoryEf has the same shape with Name as the key and a hand-written mapping between the GameConfiguration record and the Configuration entity. Two implementations, one contract, and — this is the point — the Guid id is the same in both, so a game saved to JSON and later imported into the database keeps its identity.

Contract tests: one test class, two implementations

The A4 requirement is that the same tests run against both repositories. xUnit's [Theory] with [MemberData] does exactly that: the data member yields one fresh repository per implementation, and every test method runs once for each.

public class GameRepositoryContractTests
{
public static TheoryData<IGameRepository> Repositories() => new()
{
new GameRepositoryJson(Path.Combine(Path.GetTempPath(), "games-" + Guid.NewGuid())),
new GameRepositoryEf(InMemoryDb()),
};

private static AppDbContext InMemoryDb()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open(); // in-memory SQLite lives as long as this connection
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options);
db.Database.EnsureCreated();
return db;
}

private static GameState NewState() =>
GameFactory.Create(new GameConfiguration("Tic-Tac-Toe", 3, 3, 3));

[Theory]
[MemberData(nameof(Repositories))]
public void Save_then_Get_round_trips(IGameRepository repo)
{
var state = NewState();
state.Board[1][1] = EGamePiece.X;
state.Moves.Add((1, 1));

repo.Save(state);
var loaded = repo.Get(state.Id);

Assert.Equal(state.Id, loaded.Id);
Assert.Equal(EGamePiece.X, loaded.Board[1][1]);
Assert.Single(loaded.Moves);
Assert.Equal((1, 1), loaded.Moves[0]);
}

[Theory]
[MemberData(nameof(Repositories))]
public void Save_twice_does_not_duplicate(IGameRepository repo)
{
var state = NewState();
repo.Save(state);
repo.Save(state);

Assert.Single(repo.List());
}

[Theory]
[MemberData(nameof(Repositories))]
public void Get_unknown_id_throws(IGameRepository repo) =>
Assert.Throws<KeyNotFoundException>(() => repo.Get(Guid.NewGuid()));
}

Run dotnet test and the output lists each test twice, once per repository. If the EF version passes and the JSON version fails on Save_twice_does_not_duplicate, the contract is broken — not the test. The Tests project needs Microsoft.EntityFrameworkCore.Sqlite and a reference to both DAL projects; keep the tests to the behaviour table plus a round-trip — you are testing the contract, not EF Core.

Manual injection: the composition root

The simplest DI has no library at all. One place — Program.cs — creates every object and wires it together. That place is called the composition root.

// ConsoleUI/Program.cs
var dataDir = Path.Combine(AppContext.BaseDirectory, "data");

// These two lines decide the persistence for the whole application.
IConfigRepository configs = new ConfigRepositoryJson(Path.Combine(dataDir, "configs"));
IGameRepository games = new GameRepositoryJson(Path.Combine(dataDir, "games"));

// ...or, with EF Core (options builder as in lecture 07.1):
// var db = new AppDbContext(options); db.Database.Migrate();
// IConfigRepository configs = new ConfigRepositoryEf(db);
// IGameRepository games = new GameRepositoryEf(db);

var controller = new GameController(games, configs);
var mainMenu = new Menu("Tic-Tac-Two",
[new MenuItem("N", "New game", controller.NewGame), new MenuItem("L", "Load game", controller.LoadGame)]);
mainMenu.Run();

This already satisfies "switching takes a couple of lines". Nothing below Program.cs mentions a concrete repository. That is the rule: only the composition root knows about implementations.

Solution layering

Once the dependencies are explicit, the project references tell the story:

  • GameEngine references nothing but the BCL. It does not know that files, databases or consoles exist. MenuSystem is a generic library and knows nothing about games.
  • DAL.Json and DAL.EF reference GameEngine because they store its types. They never reference each other or any UI.
  • ConsoleUI (and later WebApp) is the only project that references everything. It is the composition root. Tests references whatever it tests.

Arrows only point towards the domain; there are no cycles. The compiler refuses circular project references anyway, but the direction is a discipline: the moment GameEngine needs something from DAL.EF, the design is wrong, not the reference list. Check with dotnet list ConsoleUI reference.

A DI container: Microsoft.Extensions.DependencyInjection

Manual wiring is fine for four objects. It stops being fine when GameController needs a repository, a logger, a move provider factory and a clock, and each of those has its own dependencies. A container does the constructor-matching for you: you register interface → implementation, it builds the object graph.

dotnet add ConsoleUI package Microsoft.Extensions.DependencyInjection
var services = new ServiceCollection();

services.AddDbContext<AppDbContext>(o => o.UseSqlite(connectionString));
services.AddScoped<IConfigRepository, ConfigRepositoryEf>();
services.AddScoped<IGameRepository, GameRepositoryEf>();
services.AddTransient<GameController>();

using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();

scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.Migrate();
var controller = scope.ServiceProvider.GetRequiredService<GameController>();

GetRequiredService<GameController>() looks at the constructor of GameController, sees IGameRepository and IConfigRepository, resolves each (which in turn needs AppDbContext, which needs its options) and calls the constructor. You never wrote a new. AddDbContext comes with EF Core, which ConsoleUI already gets transitively through DAL.EF.

Lifetimes

RegistrationNew instanceTypical use
AddTransienton every resolvestateless helpers, controllers, move providers
AddScopedonce per scope (CreateScope(); in the web app: per HTTP request)DbContext, repositories
AddSingletononce per containerconfiguration, Random, caches, the menu system

Two rules that save hours of debugging:

  • A singleton must not depend on a scoped service. The container would hand the singleton one DbContext forever ("captive dependency"). ASP.NET Core throws for this in development; a console app does not, it just misbehaves.
  • DbContext is not thread-safe and grows with every tracked entity. Scoped is the right lifetime; singleton is a bug waiting for lecture 51.

Hosting. Microsoft.Extensions.Hosting wraps the container together with configuration and logging: Host.CreateApplicationBuilder(args) gives you builder.Services, builder.Configuration and builder.Logging in one object — the same shape as WebApplication.CreateBuilder(args) in the web app. Use it when you want logging in the console app; the plain ServiceCollection above is enough for A4.

Configuration: switching persistence with one value

A4 requires that the switch is a config value. appsettings.json in the ConsoleUI project:

{
"Persistence": "Db",
"DataDirectory": "data",
"ConnectionStrings": {
"Default": "Data Source=data/app.db"
}
}

The file must be copied next to the binary — in the .csproj: <None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />.

dotnet add ConsoleUI package Microsoft.Extensions.Configuration.Json
dotnet add ConsoleUI package Microsoft.Extensions.Configuration.EnvironmentVariables

The composition root now reads the value and registers the matching pair:

var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false)
.AddEnvironmentVariables()
.Build();

var services = new ServiceCollection();
var dataDir = Path.Combine(AppContext.BaseDirectory, config["DataDirectory"] ?? "data");

switch (config["Persistence"])
{
case "Json":
services.AddSingleton<IConfigRepository>(new ConfigRepositoryJson(Path.Combine(dataDir, "configs")));
services.AddSingleton<IGameRepository>(new GameRepositoryJson(Path.Combine(dataDir, "games")));
break;
case "Db":
services.AddDbContext<AppDbContext>(o => o.UseSqlite(config.GetConnectionString("Default")));
services.AddScoped<IConfigRepository, ConfigRepositoryEf>();
services.AddScoped<IGameRepository, GameRepositoryEf>();
break;
default:
throw new InvalidOperationException("Persistence must be \"Json\" or \"Db\"");
}

services.AddTransient<GameController>();

Later providers override earlier ones, so Persistence=Json dotnet run switches to files without touching the JSON file. The JSON repositories are stateless apart from a folder name, so they can be singletons; the EF ones follow the DbContext and are scoped.

Unit of Work, the light version

DbContext already is a unit of work: it tracks changes and writes them in one transaction on SaveChanges(). Our repositories call SaveChanges() at the end of every public method — one menu action, one transaction. For this application that is the whole pattern. Two things to remember from it:

  • One SaveChanges() per operation, never per entity. If "save game" writes a SavedGame and updates a statistics row, they go in the same call.
  • The context lifetime is the transaction boundary. One scope per menu action is a clean choice in the console app; the web app opens a scope per request. Use AsNoTracking() for read-only lists so the context does not accumulate entities it will never save.

The full pattern — an IUnitOfWork that owns the repositories and the only SaveChanges() — belongs to the web applications course. You do not need it for A4.

What moves to the web app

Everything above is portable. In 13.1 - Razor Pages + EF + DI the switch on Persistence lands in the web app's Program.cs unchanged, builder.Services replaces services, and page models get IGameRepository through their constructors exactly like GameController does now. The console app and the web app share GameEngine, DAL.Json, DAL.EF and the same SQLite file — a game started in one continues in the other.

Self preparation QA

  1. What is the difference between Dependency Inversion, Inversion of Control and Dependency Injection? — DIP is the principle (depend on abstractions), IoC is the pattern (the class does not create its own dependencies), DI is the technique (dependencies are passed in, usually via the constructor).
  2. Why do IGameRepository and IConfigRepository live in GameEngine and not in a DAL project? — The domain defines what it needs; DAL projects implement it. If the interface lived in DAL.Json, DAL.EF would have to reference DAL.Json, and the dependency direction would be broken.
  3. What is a composition root? — The one place in the application (Program.cs) that knows concrete implementations and wires them together. Every other class receives its dependencies and never references the container.
  4. What do contract tests prove, and how are they run against two implementations? — That both implementations obey the same behaviour rules (upsert, throw on missing key, silent delete). One [Theory] per rule with [MemberData] yielding a fresh instance of each repository.
  5. Explain the three service lifetimes and give one example of each from the game. — Transient: new object per resolve (GameController). Scoped: one per scope or request (AppDbContext, EF repositories). Singleton: one per container (GameRepositoryJson, Random).
  6. Why must a singleton never depend on a scoped service? — The singleton captures the first scoped instance and keeps it forever; with DbContext that means one context shared across all operations and threads.
  7. How does the A4 "one config value" switch work?appsettings.json holds "Persistence": "Json" or "Db"; the composition root reads it through Microsoft.Extensions.Configuration and registers the matching repository pair. Environment variables can override the file.
  8. Where does the unit of work live in our design? — In DbContext: each repository method ends with exactly one SaveChanges(), and the context's scope is the transaction boundary.