C4 — Build the Reading Progress Feature
Summary — what this page covers The Day 1 finale: build your first real feature end-to-end — spec → code → tests → a debugging exercise. You'll add a Reading Progress feature to BookTracker (an entity, a pure state machine, a service, two endpoints, a migration, and tests), then deliberately plant a bug and use Claude Code to find its root cause and fix the source. This is also the surface Day 2 builds on.
Time: ~75 minutes · Format: hands-on, solo · You start from:
checkpoint/c3-github-mcp· You end at:checkpoint/c4-reading-progress
By C3 you have a clean BookTracker, a CLAUDE.md, path-scoped rules in .claude/rules/, a
test-writer subagent, a PostToolUse build hook, and GitHub MCP wired up. Now you put it all to
work on a feature that matters. Reading Progress tracks how far a reader is through a book — current
page, status, and start/finish dates — governed by a small forward-only state machine. You'll
write the spec first, build against it, test every rule, and finish with a deliberate
debugging loop. Keep the conventions tight: this exact code becomes the agent tool in C6 and
the MCP tool in C8, so IReadingProgressService is a stability contract, not throwaway Day 1 code.
1. Prerequisites
This is a Day 1 (Claude Code) lab, so you need:
Day 1 uses Claude Code with a paid Claude plan. (Day 2's labs use the Anthropic API and a separate API key — you don't need that here.)
2. Continue from your C3 work
Keep using your working branch if you have one. Otherwise start fresh from the C3 checkpoint, then
launch Claude Code from the solution folder so it picks up CLAUDE.md, the rules, the subagent, and
the hook:
# only if you don't already have a working branch at C3
git switch -c lab checkpoint/c3-github-mcp
cd src/BookTracker
claude
Each lab starts from the previous checkpoint, and the matching tag is the answer key. If you get stuck, diff against
checkpoint/c4-reading-progress— but build it yourself first.
3. Part A — Write the spec first
Specs come before code. Ask Claude Code to draft specs/reading-progress.md as a 6-section spec.
Give it the shape and the rules; review what it produces.
Write specs/reading-progress.md for a Reading Progress feature. Use 6 sections:
1. Context & scope — track current page, reading status, start/finish dates; one record per book.
Out of scope: per-user progress, history/audit, partial-page tracking.
2. API contract — GET and PUT /api/books/{id}/reading-progress; status codes 200/400/404.
3. Data model & persistence — one row per BookId (unique); TotalPages derives from Book.TotalPages
and is NOT stored; status stored as a string; dates as DateOnly?; an AddReadingProgress migration.
4. Business rules — forward-only WantToRead -> Reading -> Completed; page 0..TotalPages inclusive;
entering Reading stamps StartedOn (today), entering Completed stamps FinishedOn (today) and pins
CurrentPage to TotalPages.
5. Tests required — list the rule cases.
6. Prohibitions & acceptance — no skipping to Completed; Completed is terminal; page never out of bounds.
You should end up with a tight spec that names the two routes, the data model, the state machine, and at least three prohibitions. This is the contract the rest of the lab builds against.
4. Part B — Build the domain (Core)
Now build the feature, starting at the center — BookTracker.Core, which depends on nothing. Drive
Claude file by file so each piece stays small and reviewable. Create:
Entities/ReadingStatus.cs—enum ReadingStatus { WantToRead, Reading, Completed }.Entities/ReadingProgress.cs—Id,BookId,Book? Book(nav),CurrentPage,Status,StartedOn/FinishedOnasDateOnly?. NoTotalPages— it's derived fromBook.TotalPages.Dtos/ReadingProgressDto.cs— holds two records:-
ReadingProgressDto:BookId,CurrentPage,TotalPages(from the book),string Status,StartedOn,FinishedOn, and a computedPercentComplete. -
UpdateReadingProgressRequest:CurrentPage,string Status. (Dates are derived by the rules, never client-supplied.)
-
Then build the heart of the feature — the pure state machine in Core/Domain/ReadingProgressRules.cs.
No EF, no I/O — just logic the service calls.
Create Core/Domain/ReadingProgressRules.cs — a pure, EF-free state machine. Shape:
Apply(ReadingProgress current, int requestedPage, ReadingStatus requestedStatus, int totalPages, DateOnly today)
It mutates `current` in place on success and returns a RuleResult(bool Ok, string? Error).
5. Encode the rules
Make ReadingProgressRules.Apply enforce exactly this — these are the rules every test in Part D
will check:
-
Forward-only status:
WantToRead → Reading → Completed. Same → same is a no-op. -
Completedis terminal — reject any transition out of it (this is the cell the Part-E bug will break). -
Page bounds: reject if
CurrentPage < 0orCurrentPage > totalPages(inclusive upper bound). -
Entering
Reading: setStartedOn = todayif it's null. -
Entering
Completed: setFinishedOn = today, forceCurrentPage = totalPages, and requireFinishedOn >= StartedOn. -
Reject everything else — e.g. skipping
WantToRead → Completed, orCompleted → Reading.
The allowed/rejected transitions:
On failure, Apply returns RuleResult(false, "<message>"); the service turns that into a
ValidationException.
6. Wire the service and data
The rules are pure; the service orchestrates them. Build, in order:
Core/Services/IReadingProgressService.cs— the Day 2 integration surface. Keep it stable.Task<ReadingProgressDto?> GetForBookAsync(int bookId, CancellationToken ct); Task<ReadingProgressDto> UpdateAsync(int bookId, UpdateReadingProgressRequest req, CancellationToken ct);Core/Interfaces/IReadingProgressRepository.cs— the persistence port (keeps Core EF-free):GetForBookAsync,AddAsync,UpdateAsync, all taking aCancellationToken.Core/Services/ReadingProgressService.cs— implementsIReadingProgressService. Loads theBookfor itsTotalPages(KeyNotFoundExceptionif the book is missing), loads the existingReadingProgressor creates one, parses the status string, callsReadingProgressRules.Apply(...)(throwingValidationExceptionon a rule break), persists via the repository, and maps toReadingProgressDto. No EF here.Data/Repositories/ReadingProgressRepository.cs— implements the port with async EF LINQ (FirstOrDefaultAsyncbyBookId;Add/Update+SaveChangesAsync(ct)). Threadsct, no raw SQL.
Then the persistence config and migration:
In BookTrackerDbContext: add DbSet<ReadingProgress> ReadingProgress, configure a Book 1—1
ReadingProgress with a UNIQUE INDEX on BookId, store Status via HasConversion<string>(), and
seed one ReadingProgress row.
Generate the migration from src/BookTracker:
dotnet ef migrations add AddReadingProgress \
--project BookTracker.Data --startup-project BookTracker.Api
7. Add the endpoints (Api)
Add Endpoints/ReadingProgressEndpoints.cs — thin, async, returning DTOs (per the api-endpoints
rule), and wire it in Program.cs:
In Program.cs, register the service and repository and map the routes:
builder.Services.AddScoped<IReadingProgressService, ReadingProgressService>();
builder.Services.AddScoped<IReadingProgressRepository, ReadingProgressRepository>();
// ...
app.MapReadingProgressEndpoints();
The endpoint catches ValidationException → 400 with the rule message and
KeyNotFoundException → 404. Apply the migration and smoke-test:
dotnet ef database update --project BookTracker.Data --startup-project BookTracker.Api
dotnet run --project BookTracker.Api
# in a second terminal
curl http://localhost:5255/api/books/1/reading-progress # 200 or 404
curl -X PUT http://localhost:5255/api/books/1/reading-progress \
-H "Content-Type: application/json" \
-d '{"currentPage":42,"status":"Reading"}' # 200, StartedOn stamped
curl -X PUT http://localhost:5255/api/books/1/reading-progress \
-H "Content-Type: application/json" \
-d '{"currentPage":999999,"status":"Reading"}' # 400 with the rule message
8. Part D — Write the tests (test-writer subagent)
Use your C2 test-writer subagent to draft xUnit tests in
BookTracker.Tests/ReadingProgressTests.cs covering every rule:
Use the test-writer subagent to write ReadingProgressTests.cs covering:
- a valid in-range page update succeeds
- CurrentPage > TotalPages is rejected
- negative CurrentPage is rejected
- WantToRead -> Reading sets StartedOn
- Reading -> Completed sets FinishedOn (>= StartedOn) and fills CurrentPage = TotalPages
- WantToRead -> Completed (skip) is rejected
- Completed -> Reading is rejected <-- this is the regression test that guards the Part E bug
- GET returns 404 when no record exists
Run them green:
dotnet test
9. Part E — The debugging exercise (plant a bug)
Your feature is correct and green. Now break it on purpose and practice the debugging loop — the same loop you'll use on real bugs all day. Introduce a bug, watch the test go red, then drive Claude to the root cause and fix the source (not the symptom):
- Plant it. In
ReadingProgressRules, make the guard allowCompleted → Reading— e.g. drop the "Completed is terminal" check. - Reproduce. Run
dotnet test. TheCompleted → Readingregression test goes red. (Your C2PostToolUsebuild hook also surfaces the break automatically as you edit.) -
Diagnose. Paste the failure into Claude Code and ask it to read the source and tests first, then explain the root cause before changing anything.
This test is now failing: [paste]. Read ReadingProgressRules and the failing test, find the root cause, and fix the source so the state machine is correct again. Don't weaken the test. - Fix and confirm. Restore the terminal-
Completedguard, re-rundotnet test, and watch it go green again.
The point isn't the bug — it's the loop: introduce → failing test → root cause → source fix → green. Never fix a symptom in the test when the defect is in the source.
✅ Checkpoint — you're done when:
-
specs/reading-progress.mdis committed (6 sections, ≥3 prohibitions). -
dotnet ef database updateapplies theAddReadingProgressmigration cleanly. -
dotnet buildanddotnet testare green, including theCompleted → Readingrejected test. -
GET/PUT /api/books/{id}/reading-progressbehave per the contract —400with the rule message,404for a missing book. -
The debugging loop ran: bug introduced → reproduced with a failing test → root cause → source fixed → green.
-
No rule violations — DTOs at the boundary, async with
CancellationToken, no raw SQL, rules inCore/Domain, data access inData.
Tag this state checkpoint/c4-reading-progress. Day 1 is complete.
What's next
C5 (Day 2, Lab 1): you switch from Claude Code to the Anthropic C# SDK and start calling
Claude from .NET code — Day 2 begins from this full Day 1 solution. The Reading Progress service you
just built is the foundation: IReadingProgressService.UpdateAsync becomes the agent tool in C6,
and GetForBookAsync becomes the MCP tool in C8. That's why the interface had to be stable.