Skip to main content

13.1 - Razor Pages + EF Core + DI: Reusing the Libraries

Recap

In 12.2 - Razor Tag Helpers you built forms with tag helpers, model binding and Post-Redirect-Get — on pages that still worked with in-memory data. In 08.1 - Repository & DI the console app got IConfigRepository and IGameRepository behind a "Persistence" switch, wired through Microsoft.Extensions.DependencyInjection. This week the two meet: WebApp references the same class libraries, registers the same repositories in its own DI container and renders the board from the same GameState the console app saves.

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

  • Reference GameEngine, DAL.Json and DAL.EF from WebApp and register AppDbContext plus both repository implementations, switched by a configuration value.
  • Explain why DbContext is scoped and run migrations from the web project (or apply them at startup).
  • Scaffold CRUD pages for configurations, read what was generated and re-route the pages through the repository interfaces.
  • Render the board with nested @for loops, make empty cells clickable with asp-route-* values and highlight the last move and the winner.
  • Describe the request lifecycle of one move (load → apply → save → redirect) and handle multi-step moves without server-side session state.
Demo code

Lecture demos: csharp-2026-fall

Where we are

Two front-ends, one set of libraries. Nothing below the UI layer changes this week.

WebApp contains pages, view models and CSS. It contains zero game rules. If you catch yourself writing if (board[r][c] == ...) inside a PageModel to decide whether a move is legal — stop, that line belongs in GameBrain. The rubric line "zero duplicated game logic between console and web" is checked at D3 by opening your Pages/ folder.

Project references

dotnet add WebApp reference GameEngine DAL.Json DAL.EF
dotnet add WebApp package Microsoft.EntityFrameworkCore.Design

DAL.EF already brings Microsoft.EntityFrameworkCore.Sqlite transitively. The Design package is only needed because dotnet ef will use WebApp as the startup project. Do not add MenuSystem — the web app has no menus.

Configuration: appsettings.json

The web app reads the same three values the console app reads. Same file path means the same SQLite database — that is what makes cross-play possible.

{
"Persistence": "Db",
"ConnectionStrings": {
"DefaultConnection": "Data Source=~/.icd0008/games.db"
},
"JsonDirectory": "~/.icd0008/saves",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

The ~ is not understood by SQLite — we replace it with the user profile folder at startup. The syllabus forbids absolute local paths in the repository, and a relative path would resolve against the working directory, which differs between ConsoleUI and WebApp. The user home is the one place both apps agree on.

Registering DbContext and repositories

Program.cs in WebApp. Compare it with the console Program.cs from lecture 08.1 — the registrations are identical, only the container is created by WebApplication.CreateBuilder instead of new ServiceCollection().

using DAL.EF;
using DAL.Json;
using GameEngine;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();

var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var connectionString = builder.Configuration
.GetConnectionString("DefaultConnection")!
.Replace("~", home);

builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(connectionString));

var persistence = builder.Configuration["Persistence"] ?? "Db";
if (persistence == "Json")
{
var dir = builder.Configuration["JsonDirectory"]!.Replace("~", home);
builder.Services.AddScoped<IConfigRepository>(_ => new ConfigRepositoryJson(dir));
builder.Services.AddScoped<IGameRepository>(_ => new GameRepositoryJson(dir));
}
else
{
builder.Services.AddScoped<IConfigRepository, ConfigRepositoryEf>();
builder.Services.AddScoped<IGameRepository, GameRepositoryEf>();
}

var app = builder.Build();

if (persistence == "Db")
{
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.Migrate();
}

if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
}

app.UseRouting();
app.MapStaticAssets();
app.MapRazorPages().WithStaticAssets();

app.Run();

Switching JSON ↔ DB in web is now one value in appsettings.json, or dotnet run --Persistence=Json, or an environment variable Persistence=Json. All three feed the same IConfiguration.

Why DbContext is scoped

AddDbContext registers AppDbContext as scoped. In a web app a scope is one HTTP request: the context is created when the request starts, shared by everything that request resolves (your PageModel, the repository the PageModel gets, anything else) and disposed when the response is sent.

LifetimeOne instance perTypical use in our app
Transientinjectionstateless helpers, GameBrain if you registered it
ScopedHTTP requestAppDbContext, ConfigRepositoryEf, GameRepositoryEf
Singletonapplicationconfiguration objects, an IMoveProvider factory

DbContext is not thread-safe and it tracks every entity it loaded. A singleton context would be shared by all concurrent requests (data corruption) and would grow forever (memory). A transient context would give the repository and the page two different contexts — a change made through one is invisible to the other until SaveChanges. Scoped is the only sensible answer, and it is why the repositories are scoped too: a scoped service may depend on a scoped DbContext, a singleton may not.

warning

Never keep a DbContext, a GameBrain or a GameState in a static field "so the next request finds it". Two browsers hitting the same field is the first bug you will not be able to reproduce on your laptop and the first one the TA will trigger at D3.

Migrations from the web project

The migrations live in DAL.EF/Migrations since A4. Nothing new is needed for the web app unless you change the model. When you do:

dotnet ef migrations add AddPlayerTokens --project DAL.EF --startup-project WebApp
dotnet ef database update --project DAL.EF --startup-project WebApp

--project says where the migration files go, --startup-project says which app's DI configuration to run to construct the DbContext. If DAL.EF has an IDesignTimeDbContextFactory<AppDbContext> from the console phase, --startup-project is optional.

The Database.Migrate() call in Program.cs applies pending migrations at startup. It is idempotent — running it from both apps is fine, the migration history table remembers what was applied. Pick one convention and document it in the README:

  • Explicit: dotnet ef database update before the first run, no code at startup. Cleaner separation, one more command in the README.
  • Automatic: Database.Migrate() in both Program.cs files. Fresh clone just works, which is what item 1 of the D3 demo wants.
danger

Never mix Database.EnsureCreated() with migrations. EnsureCreated builds the schema without a migration history, and the next Migrate() fails on "table already exists".

Scaffolding CRUD pages for configurations

Process

  • Define your domain models (done — GameConfiguration, GameState)
  • Configure DbContext and ModelBuilder (done — AppDbContext with Configuration and SavedGame)
  • Scaffold (generate) CRUD pages for the entities you want to edit in the browser

Prerequisites

  • EF tools and the UI scaffolding tool installed globally
dotnet tool install --global dotnet-ef
dotnet tool install --global dotnet-aspnet-codegenerator
  • NuGet packages in WebApp: Microsoft.EntityFrameworkCore.Design and Microsoft.VisualStudio.Web.CodeGeneration.Design (Microsoft.EntityFrameworkCore.Sqlite comes through DAL.EF)
dotnet add WebApp package Microsoft.VisualStudio.Web.CodeGeneration.Design
  • Database created and migrated (dotnet ef database update or the startup Migrate())

Run the generator

info

cd into the WebApp directory first — the generator runs your Program.cs to discover the DbContext, so DI must be configured and the project must build.

dotnet aspnet-codegenerator razorpage \
-m Configuration \
-dc AppDbContext \
-udl \
-outDir Pages/Configs \
--referenceScriptLibraries
  • -m — name of the entity class (the EF entity in DAL.EF, not the engine record)
  • -dc — data context class
  • -udl — use default layout
  • -outDir — where to generate the output
  • --referenceScriptLibraries — add client-side validation scripts to Create and Edit

You get Index, Create, Edit, Delete and Details pages, each with a .cshtml and a .cshtml.cs.

After scaffolding

danger

Inspect and modify the generated pages to your liking. This is just the starting point!

The generated code talks to AppDbContext directly. That works, but it silently defeats the "Persistence": "Json" switch — in JSON mode these pages would still write to SQLite. Read the generated code to learn the shape, then re-route it through IConfigRepository.

Reading the generated PageModel

The generated Index page model, trimmed of comments:

public class IndexModel : PageModel
{
private readonly AppDbContext _context;

public IndexModel(AppDbContext context)
{
_context = context;
}

public IList<Configuration> Configuration { get; set; } = default!;

public async Task OnGetAsync()
{
Configuration = await _context.Configurations.ToListAsync();
}
}

and Create:

public class CreateModel : PageModel
{
private readonly AppDbContext _context;

public CreateModel(AppDbContext context)
{
_context = context;
}

public IActionResult OnGet()
{
return Page();
}

[BindProperty]
public Configuration Configuration { get; set; } = default!;

public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}

_context.Configurations.Add(Configuration);
await _context.SaveChangesAsync();

return RedirectToPage("./Index");
}
}

Everything you learned in the previous weeks is in these thirty lines:

  • Constructor injection. The framework creates the PageModel per request and asks the DI container for every constructor parameter. AppDbContext is scoped, so the page gets the request's context.
  • Async handlers. OnGetAsync returns Task, OnPostAsync returns Task<IActionResult>. ToListAsync and SaveChangesAsync release the request thread while SQLite works. The rules from lecture 10.2 apply: always await, one operation at a time per context.
  • Model binding and validation. [BindProperty] fills Configuration from the form, ModelState.IsValid reflects the data annotations on the entity.
  • Post-Redirect-Get. A successful POST ends with a redirect, so F5 does not create a second configuration.

Re-routing through the repository

The same two pages against IConfigRepository, with a primary constructor and an input DTO that carries the validation attributes (the engine record stays attribute-free):

public class ConfigInput
{
[Required, StringLength(40)]
public string Name { get; set; } = "";

[Range(3, 20)] public int BoardWidth { get; set; } = 7;
[Range(3, 20)] public int BoardHeight { get; set; } = 6;
[Range(3, 20)] public int WinLength { get; set; } = 4;

public GameConfiguration ToConfiguration() =>
new(Name, BoardWidth, BoardHeight, WinLength);
}

public class IndexModel(IConfigRepository configs) : PageModel
{
public List<GameConfiguration> Configs { get; private set; } = new();

public void OnGet() => Configs = configs.List();
}

public class CreateModel(IConfigRepository configs) : PageModel
{
[BindProperty]
public ConfigInput Input { get; set; } = new();

public void OnGet() { }

public IActionResult OnPost()
{
if (Input.WinLength > Math.Max(Input.BoardWidth, Input.BoardHeight))
{
ModelState.AddModelError(nameof(Input.WinLength), "Win length does not fit on the board.");
}
if (!ModelState.IsValid) return Page();

configs.Save(Input.ToConfiguration());
return RedirectToPage("./Index");
}
}

The handlers are synchronous because the A4 repository interface is synchronous, and SQLite on a local file is fast enough that nobody can tell. If you extended the interface with ListAsync / SaveAsync in lecture 10.2, use OnGetAsync / OnPostAsync and await — the page shape is the same. Do not mix: configs.SaveAsync(...).Result deadlocks are a classic.

In the .cshtml files, replace Model.Configuration with Model.Configs or Model.Input; the tag helpers do not care where the data came from.

Rendering the board

The board is a GameState from the repository, shown as an HTML <table>. Every empty cell is a tiny form that POSTs the coordinates back to the same page.

The PageModel

public class PlayModel(IGameRepository games) : PageModel
{
public GameState Game { get; private set; } = default!;
public EGamePiece Winner { get; private set; }
public bool IsDraw { get; private set; }
public bool IsGameOver => Winner != EGamePiece.Empty || IsDraw;
public (int Row, int Col)? LastMove => Game.Moves.Count > 0 ? Game.Moves[^1] : null;

public IActionResult OnGet(Guid gameId)
{
var game = games.Get(gameId);
if (game is null) return NotFound();

Load(game);
return Page();
}

public IActionResult OnPost(Guid gameId, int row, int col)
{
var game = games.Get(gameId);
if (game is null) return NotFound();

var brain = new GameBrain(game);
if (!brain.MakeMove(row, col))
{
TempData["Error"] = $"Illegal move ({row}, {col}).";
return RedirectToPage(new { gameId });
}

games.Save(game);
return RedirectToPage(new { gameId });
}

private void Load(GameState game)
{
Game = game;
var brain = new GameBrain(game);
Winner = brain.CheckWin();
IsDraw = brain.IsDraw();
}
}

gameId comes from the route template, row and col from the query string that the form tag helper builds. MakeMove returns false for an occupied cell, a full column, a move out of turn — whatever your rules say. The page does not know the rules, it only knows "the brain said no".

The Razor page

@page "{gameId:guid}"
@using GameEngine
@model WebApp.Pages.Games.PlayModel
@{
ViewData["Title"] = Model.Game.Config.Name;
var last = Model.LastMove;
}

<h1>@Model.Game.Config.Name</h1>

@if (TempData["Error"] is string error)
{
<div class="alert alert-warning">@error</div>
}

<p class="status">
@if (Model.Winner != EGamePiece.Empty) { <strong>@Model.Winner wins!</strong> }
else if (Model.IsDraw) { <strong>Draw.</strong> }
else { <span>Next move: @Model.Game.NextMoveBy</span> }
</p>

<table class="board">
@for (var r = 0; r < Model.Game.Config.BoardHeight; r++)
{
<tr>
@for (var c = 0; c < Model.Game.Config.BoardWidth; c++)
{
var piece = Model.Game.Board[r][c];
var css = piece switch
{
EGamePiece.X => "cell-x",
EGamePiece.O => "cell-o",
_ => "cell-empty"
};
if (last is not null && last.Value.Row == r && last.Value.Col == c)
{
css += " last-move";
}
<td class="@css">
@if (piece == EGamePiece.Empty && !Model.IsGameOver)
{
<form method="post" asp-page="./Play"
asp-route-gameId="@Model.Game.Id"
asp-route-row="@r" asp-route-col="@c">
<button type="submit" class="cell-btn"
aria-label="Play row @r column @c"></button>
</form>
}
else if (piece != EGamePiece.Empty)
{
<span>@piece</span>
}
</td>
}
</tr>
}
</table>

<a asp-page="./Index">Back to games</a>

The form tag helper turns asp-route-gameId into the route segment and asp-route-row / asp-route-col into the query string, giving action="/Games/Play/3f2a...?row=2&col=4", and it adds the antiforgery token for free. For Connect Four the column is the whole move — render one form per column above the board and drop the row value.

info

Why a form per cell and not a link with asp-page-handler="Move"? A link is a GET, and a GET must not change state: browsers prefetch links, the back button replays them, a crawler on a shared machine would play your game. POST for moves, GET for looking.

CSS

wwwroot/css/site.css:

.board { border-collapse: collapse; margin: 1rem 0; }
.board td {
width: 2.5rem; height: 2.5rem;
border: 1px solid #888;
text-align: center; font-size: 1.5rem; padding: 0;
}
.board td form, .cell-btn { width: 100%; height: 100%; margin: 0; }
.cell-btn { background: transparent; border: 0; cursor: pointer; }
.cell-btn:hover { background: #e8eefc; }
.cell-x { color: #c0392b; }
.cell-o { color: #2980b9; }
.last-move { background: #fff3b0; }
.win-cell { background: #b6f2b6; }

A CSS grid works just as well: <div class="board-grid" style="grid-template-columns: repeat(@Model.Game.Config.BoardWidth, 2.5rem)"> with one <div> per cell and display: grid in the stylesheet. Tables give you rows and columns without any CSS, grids give you nicer control of the layout. Nine Men's Morris is neither — position the 24 points absolutely inside a <div> with left/top percentages from a small lookup table.

Highlighting the last move and the winner

The last move is Game.Moves[^1] — one comparison per cell, done above. For the winner you have two levels:

  • Show it in the status line. CheckWin() gives you the piece, that is enough for the rubric.
  • Colour the winning cells. Add IReadOnlyList<(int Row, int Col)>? GetWinningLine() to GameBrain — it is the same scan CheckWin() does, returning the coordinates instead of a piece — and add win-cell to cells contained in it.

Keep both in the engine. The console UI wants the same information, and a second implementation in Razor is exactly the duplication the rubric penalises.

Game over

When IsGameOver is true the loop renders no forms, so there is nothing to click; the status line says who won. Add a "New game with the same configuration" link to Create with asp-route-configName="@Model.Game.Config.Name". Do not delete finished games automatically — the list page should show them with their result.

Multi-step moves

Nine Men's Morris and Tic-Tac-Two need two clicks for one move: select a piece, then a destination (and for a mill, a third click to remove). HTTP has no memory between the clicks, so the selection must live somewhere.

Route values (recommended). The selection is part of the URL: the first click is a plain GET link that adds ?fromRow=2&fromCol=1, the page re-renders with that piece highlighted and the legal destinations as POST forms. Refresh keeps the selection, the back button un-selects, no server state, works in two tabs.

public (int Row, int Col)? From { get; private set; }

public IActionResult OnGet(Guid gameId, int? fromRow, int? fromCol)
{
var game = games.Get(gameId);
if (game is null) return NotFound();

Load(game);
if (fromRow is not null && fromCol is not null)
{
From = (fromRow.Value, fromCol.Value);
}
return Page();
}

public IActionResult OnPostMove(Guid gameId, int fromRow, int fromCol, int toRow, int toCol)
{
var game = games.Get(gameId);
if (game is null) return NotFound();

var brain = new GameBrain(game);
if (!brain.MovePiece((fromRow, fromCol), (toRow, toCol)))
{
TempData["Error"] = "That piece cannot move there.";
}
else
{
games.Save(game);
}
return RedirectToPage(new { gameId });
}

Inside the cell loop:

@if (Model.From is null && piece == Model.Game.NextMoveBy)
{
<a asp-page="./Play" asp-route-gameId="@Model.Game.Id"
asp-route-fromRow="@r" asp-route-fromCol="@c">@piece</a>
}
else if (Model.From is not null && Model.LegalDestinations.Contains((r, c)))
{
<form method="post" asp-page="./Play" asp-page-handler="Move"
asp-route-gameId="@Model.Game.Id"
asp-route-fromRow="@Model.From.Value.Row" asp-route-fromCol="@Model.From.Value.Col"
asp-route-toRow="@r" asp-route-toCol="@c">
<button type="submit" class="cell-btn"></button>
</form>
}

LegalDestinations is brain.GetLegalMoves() filtered by the selected piece — computed in Load, not in the view. Selecting via GET is fine: nothing changes in the database until the POST.

TempData (alternative). TempData["From"] = $"{r},{c}" survives exactly one redirect and is stored in a cookie by default. It works, but a refresh loses the selection and two tabs on the same game fight over the cookie. Use it for one-shot messages ("Illegal move"), not for selection state.

Tic-Tac-Two's grid move is a separate small form with four direction buttons and its own handler (OnPostGrid(Guid gameId, string direction)), only rendered when brain.CanMoveGrid() says the unlock threshold is reached.

Request lifecycle: load → apply → save → redirect

Every move is one POST, and every POST does the same four things.

Things that follow from this picture:

  • The game id in the URL is the only memory the server has of "which game". Nothing is cached between requests; every request is a fresh PageModel, a fresh DbContext, a fresh GameBrain around a freshly loaded state.
  • TempData bridges the redirect for one-shot messages, ViewData carries values from the PageModel to the view inside one request (the title, for example). Neither is game state.
  • Two POSTs for the same game at the same moment both load the same state and both save — the second overwrites the first. With one player per browser and a whose-turn check this is rare; lecture 14.1 adds the check and an optimistic concurrency guard.
  • Load and save are the repository's problem. If the JSON repository and the EF repository behave identically (your A4 contract tests say they do), the page cannot tell which one it got.

Self preparation QA

  1. Why is DbContext registered as scoped and not singleton or transient? — It is not thread-safe and tracks loaded entities; one instance per HTTP request gives every service in the request the same unit of work and disposes it when the response is sent.
  2. Which projects does WebApp reference and which one must it not reference?GameEngine, DAL.Json, DAL.EF; not MenuSystem (no menus) and it never copies rules out of GameEngine.
  3. How do you run a migration when the DbContext lives in DAL.EF and the configuration in WebApp?dotnet ef migrations add Name --project DAL.EF --startup-project WebApp, or a design-time factory in DAL.EF.
  4. Why are the scaffolded pages only a starting point? — They talk to AppDbContext directly, which bypasses the repository interfaces and silently breaks the JSON ↔ DB switch; you re-route them through IConfigRepository.
  5. Why is a move a POST from a form and not a GET link? — GET must be safe: browsers prefetch, the back button replays, and a mutating GET would let a refresh or a crawler make moves.
  6. Where does the selected piece live between the two clicks of a multi-step move? — In the URL as route/query values (?fromRow=..&fromCol=..); the first click is a GET that changes nothing, the second is the POST.
  7. What are the four steps of a move request? — Load the state through IGameRepository, apply the move through GameBrain, save through the repository, redirect to the GET of the same page.
  8. What can TempData be used for and what not? — One-shot messages across a single redirect (illegal move), not game state or selections — it is a cookie, lost on refresh and shared across tabs.