Three parts in, ClinicLive is still made of paper: Part 3 produced a spec, Part 4 a reviewed schema. Today the repository gets a heartbeat — a Blazor Server project, ASP.NET Identity, the SQLite→PostgreSQL swap, a first migration and a seeder. And we adopt the rule that makes AI scaffolding safe to accept at all: never ship a file you haven't read.
Follow along: git clone https://github.com/rahulvyas777/clinic-live and
git checkout part-05. Every commit message in that repo begins with the exact prompt that
produced it — the history is the tutorial.
Here's the whole destination on one card, so every part of the build has a place to point at:
Why Blazor Server for this app
Two reasons, one obvious and one sneaky. The obvious one: C# everywhere. The spec, the schema, the services, the pages — one language, one debugger, one deployment. For a solo developer pairing with an AI, that also means every prompt and every review happens in a single mental model.
The sneaky reason is the seed of Parts 7 and 8: Blazor Server already runs on SignalR. Every interactive page holds a live connection to the server — the circuit — which is exactly the plumbing a self-updating waiting-room board and a staff chat need. We're not choosing a framework and then bolting real-time onto it; we're choosing a framework that is real-time under the paint. If Blazor itself is new to you, our Blazor beginner series starts from zero — this series assumes you've at least skimmed it.
Model pick: Sonnet for this whole part. Scaffolding and a database swap are mechanical
work with well-trodden answers — you don't need Opus to run dotnet new. Save the expensive
thinking for the parts where design decisions compound (the hubs in Part 7, the architecture call in
Part 8).
The scaffold prompt
Verbatim, from the commit "Scaffold Blazor Server solution with ASP.NET Identity":
Create a new Blazor project called ClinicLive under src/, interactive
server render mode, with Individual account auth (staff will log in;
patients never will). Add a solution file. Don't add any features yet —
I want to read the template output before we touch it.
Note the last sentence. It's not politeness — it's the workflow. The AI runs the equivalent of
dotnet new blazor -int Server -au Individual, and what the template hands you is substantial:
Blazor Server components wired for interactive rendering, ASP.NET Identity with its entire Account surface —
login, password reset, two-factor, passkeys, thirty-odd .razor files — and EF Core connected to
a throwaway SQLite database. That's thousands of lines of code you didn't write, about to become your
responsibility.
The rule: never ship a file you haven't read
So before touching anything, we did the least glamorous thing in this series: read
Program.cs top to bottom, asking the AI to explain any line we couldn't explain ourselves.
Top to bottom it goes: Razor components with interactive server mode, cascading authentication state,
Identity's cookie schemes, the EF Core registration (SQLite — we'll fix that), Identity core options,
a no-op email sender, then the middleware pipeline and endpoints. Twenty minutes, and now nothing in the
file is magic.
That read paid for itself twice within the hour:
-
It surfaced
options.SignIn.RequireConfirmedAccount = true— the template default. Our staff accounts come from a seeder; there is no email pipeline to confirm anything. Left alone, every seeded login would bounce forever. - It made the database swap a precise, confident prompt instead of a vague "use Postgres please" — we knew exactly which registrations had to change.
It's also worth saying out loud: some of what a template gives you, you don't actually want. We'll pull hard on that thread in Part 11's hardening pass — for now, just keep the inventory in your head.
Swapping SQLite for PostgreSQL
The next commit's prompt, verbatim:
Replace the template's SQLite with PostgreSQL: Npgsql provider,
snake_case table and column names to match docs/schema.md, connection
string for the docker-compose database (localhost:5499). Also turn OFF
RequireConfirmedAccount and say why in a comment — staff accounts are
seeded, there is no email pipeline, and confirmed-account lockout is
the #1 template gotcha. Delete the SQLite migration; we'll regenerate
for Postgres.
The database itself is one docker-compose.yml away (new to containers? our
Docker explainer has you covered):
services:
db:
image: postgres:18
container_name: cliniclive-pg
environment:
POSTGRES_USER: cliniclive
POSTGRES_PASSWORD: cliniclive
POSTGRES_DB: cliniclive
ports:
- "5499:5432"
And the registration in Program.cs becomes two honest lines. The
UseSnakeCaseNamingConvention() call (from the EFCore.NamingConventions package) is
what keeps C# feeling like C# while the database speaks native Postgres — Appointment.StartsAt
in code, appointments.starts_at in psql, no per-column mapping anywhere:
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString).UseSnakeCaseNamingConvention());
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
// Staff accounts are created by an admin/seeder, not self-service signup —
// no email pipeline exists, so confirmed accounts would lock everyone out.
options.SignIn.RequireConfirmedAccount = false;
...
What the AI got wrong: three real ones this part. First, its swap kept the
Microsoft.EntityFrameworkCore.Sqlite package in the csproj "for tests" — tests that didn't
exist, against a database we'd just decided to leave. Removed; tests get their own real Postgres in
Part 9. Second, it never flagged
RequireConfirmedAccount as a problem until we hit the lockout and asked, in plain words,
"why can't I log in?" — at which point it named the setting instantly. AI is superb at answering the
question you ask and unreliable at volunteering the question you should have asked. Third, its first
seeder draft used a real, registrable domain for demo emails. Our privacy rule for everything in this
series: reserved .test addresses only, fictional names, and phone numbers on the unassigned
+00 prefix — demo data that cannot collide with a real person.
The domain model, and the index that isn't optional
The third commit of this part turns Part 4's schema into EF Core code. The prompt, verbatim:
Implement docs/schema.md as EF Core entities in a Domain folder:
Patient, Appointment (status enum stored as text), QueueEntry,
ChatMessage. Configure in OnModelCreating: max lengths, unique phone,
unique confirmation code, and the partial unique index so two ACTIVE
appointments can't share a slot ('status NOT IN (Cancelled, NoShow)').
All timestamps timestamptz via UTC DateTimes. Generate the
InitialCreate migration, add a docker-compose for postgres:18, and an
idempotent seeder: Reception + Practitioner roles, two staff logins,
and demo patients/appointments in Development only.
The heart of what came back is the Appointment configuration:
builder.Entity<Appointment>(e =>
{
// Stored as text, not an int — readable in psql, safe to reorder the enum.
e.Property(a => a.Status).HasConversion<string>().HasMaxLength(20);
e.Property(a => a.ConfirmationCode).HasMaxLength(6);
e.HasIndex(a => a.ConfirmationCode).IsUnique();
// Two ACTIVE appointments can never share a slot; a cancelled one frees it.
e.HasIndex(a => a.StartsAt)
.IsUnique()
.HasFilter("status NOT IN ('Cancelled', 'NoShow')");
});
That partial unique index is the single most important line in the codebase — Part 4's schema review is where
it came from (from us, not the AI), and Part 6 will show it winning a race that no C# check can win. Which is
exactly why we didn't take EF Core's word for it. C# saying .HasFilter(...) is a promise;
pg_indexes is the receipt:
docker exec -it cliniclive-pg psql -U cliniclive -c \
"SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'appointments';"
Four rows come back, and the one that matters — ix_appointments_starts_at — reads as a
UNIQUE index on starts_at ending in a WHERE clause that excludes
Cancelled and NoShow. The paper rule from Part 4 is now enforced by the storage
engine itself. (Indexes fuzzy? Our indexes explainer is a
five-minute refresher.)
The seeder rounds out the skeleton: it applies migrations on startup, creates the
Reception and Practitioner roles, two staff logins
(reception@cliniclive.test and practitioner@cliniclive.test), and — in
Development only — four fictional patients with appointments this morning. Idempotent, so it's safe on every
run:
// .test is a reserved TLD (RFC 2606) — these addresses can never be real.
await EnsureUserAsync(users, "reception@cliniclive.test", ReceptionRole);
await EnsureUserAsync(users, "practitioner@cliniclive.test", PractitionerRole);
Read this before Part 6: the template registers the database with plain
AddDbContext — a scoped context per request. That's fine today, because nothing interactive
touches the database yet. But interactive Blazor components outlive a request, and the moment
they start querying, we switch to AddDbContextFactory. The comment that lands in
Program.cs when we do says it best: "Factory, not plain AddDbContext: interactive Blazor
components outlive a request, so each operation needs its own short-lived context. (The factory also
registers a scoped ApplicationDbContext, which Identity keeps using.)"
The meter: the scaffold, the swap, the domain model, the migration and the seeder — all on Sonnet — added roughly $0.50, taking the running total to ≈ $1.10. The most expensive thing in this part wasn't tokens; it was the twenty minutes of human reading. Cheap at the price.
Checkpoint: docker compose up -d then dotnet run starts clean;
you can log in as reception@cliniclive.test; \dt in psql shows the four domain
tables in snake_case; pg_indexes shows the partial unique index on
appointments.starts_at; and the demo patients are seeded. You can explain every line of
Program.cs — because you've read it.
A skeleton with a database and logins is a fine thing, but nobody can book an appointment yet. Next we build the first real feature — the booking flow — and put the prompt→review→refine loop through its paces: Part 6: CRUD with an AI pair.