Path to AI Architect · Chapter 6 of 25
AI-Assisted Full-Stack Application Development
Direct AI to build integrated frontend and backend features, and verify the contracts between layers.
29 min read Slides for this chapter
Opening scenario: “I signed up and my plan disappeared”#
Chapter 5 left Maya with a design: one FastAPI server divided into modules, one React app, one PostgreSQL database, and ADR-001 saying which module owns what. It also left her with code that predated the design. The accounts feature had come out of a single agent session in the busy week from chapter 5, and nobody had looked at it closely.
Her testers did. Within two days of turning accounts on, she had four reports:
- “I made a plan, then signed up so I could use it on my laptop. My plan disappeared.” Before accounts, each plan belonged to an anonymous browser session (chapter 4). Signing in created a new session tied to the account, and nothing moved the plan across.
- A tester who works in security tried signing in with a made-up email and got “No account with that email.” With a real address she got “Wrong password.” The difference tells anyone which emails have accounts.
- The same tester asked how passwords were stored. The answer was one fast hash function call, the kind built for checking file downloads, not for protecting passwords.
- A student changed the number at the end of
/plan/42to/plan/43and saw a stranger's exam plan. The agent's tests had all used one test user, so no test ever asked for someone else's plan.
Each bug had a reasonable explanation, and each was serious. Maya didn't want to patch them one by one into code that had no owner and didn't follow ADR-001. She decided to rebuild accounts properly, inside the boundaries she had just drawn, as a series of small features that each work from the screen down to the database.
Why it matters#
A full-stack feature crosses every layer: a screen, a request, a route, a rule, a query, a table. A bug can hide in any layer, or in the gap between two of them. The gaps are where agent-built software tends to break. An agent asked for a settings screen builds a good settings screen. An agent asked for a settings endpoint builds a good endpoint. Neither knows exactly what the other chose, unless you wrote it down first.
Identity raises the stakes. Once students have accounts, the system holds personal data and must keep each student's data away from every other student. Broken access control, where users can act outside their intended permissions, sits first in the OWASP Top 10 for 2021, and OWASP reports that 94% of the applications it tested were checked for some form of it (OWASP, 2021). Maya's /plan/43 bug is the classic example.
This chapter shows how to direct agents through full-stack work so that the layers agree, the data stays with its owner, and every increment is checked end to end before the next one starts.
Learning objectives#
After this chapter you will be able to:
- Structure a React frontend into routes, components, and clearly separated local and server state.
- Structure a FastAPI backend into routes, services, and data access, with typed schemas and one error shape.
- Design user, profile, plan, and activity data, and explain the difference between authentication and authorization.
- Use transactions and reviewed migrations to keep data consistent as the schema changes.
- Direct agents to build vertical slices from a shared contract, and check each slice with integration tests.
- Check a feature's loading, empty, and error states, accessibility, mobile layout, and basic performance.
6.1 Frontend architecture#
Component trees and route structure#
A React app is a tree of components, small functions that each draw part of the screen. At the top of the tree sit routes: one per address in the app, each drawing a page. Write the route list down before an agent builds pages, because it is the frontend's table of contents.
For the rebuilt Learning Coach:
| Route | Page | Who can see it |
|---|---|---|
/ |
Welcome and “make a plan” | Anyone |
/sign-in |
Sign in or create an account | Anyone |
/plan |
This week's tasks (US-3) | Anyone with a plan, signed in or not |
/plan/new |
Exam details form (US-2) | Anyone |
/progress |
Progress against the plan (US-5) | Anyone with a plan |
/settings |
Profile, time zone, delete account (US-6) | Signed-in students |
The last column is a convenience, not a security rule. Hiding the settings link from anonymous visitors saves them a confusing page; it stops nobody. The server decides who may read or change anything, as section 6.3 explains.
Below each page, keep the tree shallow and named for what the student sees: PlanPage contains WeekHeader, TaskList, and RebalanceButton; TaskList contains TaskRow. When you ask an agent to change a feature, name the component. “Change how TaskRow shows overdue tasks” is a much tighter request than “make overdue tasks look different”.
Forms, validation, errors, and accessibility#
Forms are where students hand you data, and where most of their frustration happens. Three rules keep them honest.
The server's validation is the real one. The frontend can check that the exam date is in the future, so the student sees the problem immediately. That check is a courtesy. The browser is outside the trust boundary, and the same rules must be enforced by the server's Pydantic schemas (section 6.2).
Errors are described in text, next to the field. WCAG success criterion 3.3.1 asks that when an input error is detected automatically, the field in error is identified and the error is described to the user in text. Redrawing the form with a red border and no explanation doesn't meet it (W3C, Understanding SC 3.3.1). The Learning Coach PRD already commits to WCAG 2.2 Level AA (NFR-4), so this isn't optional.
One error shape makes this cheap. Chapter 5 gave every API error the same form, with a field when one input is to blame. The form can then put the server's message next to the right input without special code for each endpoint:
// web/src/ui/TextField.tsx
type Props = { name: string; label: string; error?: string } & React.InputHTMLAttributes<HTMLInputElement>;
export function TextField({ name, label, error, ...input }: Props) {
const errorId = `${name}-error`;
return (
<div>
<label htmlFor={name}>{label}</label>
<input id={name} name={name} aria-invalid={!!error} aria-describedby={error ? errorId : undefined} {...input} />
{error && <p id={errorId} role="alert">{error}</p>}
</div>
);
}
aria-invalid and aria-describedby let screen readers announce that the field has a problem and read the message. Because the wiring lives in one shared component, every form gets it without anyone remembering.
Local versus server state#
Chapter 5's first bug was a plan kept in two places. The frontend version of that rule is to separate two kinds of state:
- Server state is data the server owns: plans, tasks, the profile. The frontend holds a copy only to show it. It fetches the data, may cache it, and refetches after changes.
- Local state belongs to the screen: what's typed in a form that hasn't been sent, which panel is open, which task is selected.
React's own guidance on structuring state lists principles that apply to both: avoid state that contradicts other state, avoid storing anything you can calculate from what you already have, and avoid keeping the same data in two places. One of its examples stores the selected item's ID rather than a copy of the item, so the two can never drift apart (React, Choosing the State Structure).
In practice: don't store a “progress percent” next to the task list, because it can be calculated from the tasks. Don't copy the plan into a second state variable “for editing”. Keep the ID of the task being edited and look the task up. Agents break these rules often, because adding one more state variable is the quickest way to make a feature appear to work.
Component reuse and design systems#
A design system is a shared set of components and rules that make an app look and behave consistently. For an MVP it can be very small: a Button, the TextField above, a PageState component for loading, empty, and error (section 6.5), and a page layout.
The point for an architect is less about looks than about control. Every agent session that can't find a suitable component will make a new one, and after a month you have five buttons with five focus styles, some of which a keyboard user can't see. Put the shared components in web/src/ui/, list them in the project instructions file, and add one line: “Use components from web/src/ui/. Don't create new base components without asking.”
6.2 Backend architecture#
API routes, controllers, and business services#
On the server, split each module into three layers. The names vary between frameworks; the jobs don't.
| Layer | Job | Learning Coach example |
|---|---|---|
| Route (often called a controller) | Speak HTTP: read the request, find the signed-in student, call a service, choose the status code | GET /api/plans/{plan_id} in plans/routes.py |
| Service | Apply the product's rules; decide what happens | plans/service.py: create, rebalance, claim plans |
| Data access | Read and write the database | plans/queries.py: SQL scoped to an owner |
Routes stay thin, so the rules can be tested without HTTP. Data access stays dumb, so every query can be reviewed for ownership checks in one place.
FastAPI supports this split directly. Its APIRouter, which the documentation describes as a kind of “mini FastAPI”, lets each module declare its own routes in its own file, and the main app includes each router (FastAPI, Bigger Applications). That fits ADR-001: each module's routes live with the module, and backend/coach/main.py wires the modules together.
Input validation and typed schemas#
Chapter 5 defined request and response models with Pydantic. One setting deserves attention. By default, Pydantic models ignore extra data they weren't told about; setting extra to 'forbid' turns unknown fields into a validation error (Pydantic, Models). Ignoring extras is friendly for reading responses (chapter 5's tolerant reader). For requests your own frontend sends, it hides mistakes, as the failure clinic shows. Forbid extras on request models:
# backend/coach/accounts/schemas.py
from pydantic import BaseModel, ConfigDict, Field
class ProfileUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str = Field(min_length=1, max_length=60)
time_zone: str = Field(min_length=1, max_length=64)
The schemas also feed the frontend. FastAPI publishes an OpenAPI document from them, and a tool such as openapi-typescript turns an OpenAPI document into TypeScript types (openapi-typescript, Introduction). The frontend then imports components["schemas"]["ProfileUpdate"] instead of a hand-written copy. If the backend renames a field, regenerating the types makes every frontend use of the old name fail to compile. (Tool details checked October 2026; check the current documentation before you set it up.)
Background tasks and queues#
Some work shouldn't make the student wait: sending a welcome email, extracting text from uploaded notes. FastAPI's BackgroundTasks runs a function after the response has been sent, which suits small jobs such as an email notification. For heavy work, FastAPI's documentation suggests a dedicated tool such as Celery, with a message queue like RabbitMQ or Redis (FastAPI, Background Tasks).
A rule of thumb for choosing: background tasks run inside the server process that answered the request. If losing the work in a restart would be acceptable (a welcome email you could resend), a background task is fine. If the work must happen, such as the note extraction from chapter 5 or deleting an account's data within seven days (NFR-6), record it in the database and let a separate job pick it up, so a restart only delays it.
Error handling and idempotency#
Chapter 5 specified the error shape and the retry rules. Implement each in one place so agents don't reinvent them per endpoint:
- Services raise named errors, such as
PlanNotFoundorPlanInputInvalid(field="exam_date"). They don't know about HTTP. - One exception handler in
main.pyturns each named error into the status code and the{ "error": { "code", "message", "field" } }body. An unexpected exception becomes a plain 500 with a reference number, and the details go to the server log, never to the browser. - Idempotency keys live in a table with a unique constraint on the student and the key. When the same key arrives again, the server returns the stored result. The database, not the code, guarantees that two copies of a request can't both succeed.
6.3 Persistence and user identity#
User, profile, plan, and activity models#
With accounts, the data has a new center: the student. Here is the rebuilt model.
erDiagram
USERS ||--o| PROFILES : has
USERS ||--o{ SESSIONS : "signed in through"
USERS ||--o{ PLANS : owns
SESSIONS ||--o{ PLANS : "owns before sign-in"
PLANS ||--|{ TASKS : contains
USERS ||--o{ ACTIVITY : records
USERS {
uuid id PK
text email UK
text provider_subject UK
timestamptz adult_confirmed_at
timestamptz created_at
}
PROFILES {
uuid user_id PK
text display_name
text time_zone
}
SESSIONS {
text id PK
uuid user_id FK "null while anonymous"
timestamptz expires_at
}
PLANS {
uuid id PK
uuid user_id FK "null until claimed"
text session_id FK "null once claimed"
date exam_date
text source
}
TASKS {
uuid id PK
uuid plan_id FK
date week_starts_on
text title
boolean done
}
ACTIVITY {
uuid id PK
uuid user_id FK
uuid task_id FK
text kind
timestamptz at
}
A few decisions are visible in it:
- Users and profiles are separate.
usersholds identity: who this is and how they sign in.profilesholds preferences the student edits. Identity changes rarely and carefully; preferences change often. Keeping them apart means the settings screen can't accidentally rewrite an email address. - A plan has exactly one owner at a time: an anonymous session before sign-in, a user after. A database check constraint can say so: exactly one of
user_idandsession_idis set. - Progress is calculated, not stored. US-5's progress is the share of tasks done, calculated when asked.
activityis an append-only record of what happened (task ticked, task unticked, plan rebalanced), which later lets the coach show trends without guessing. adult_confirmed_atrecords NFR-7, the confirmation that the student is 18 or older, with the time it happened. What counts as sufficient age confirmation depends on where you operate, so this needs a legal review before launch.
Authentication and authorization fundamentals#
Two words that sound alike do different jobs:
- Authentication answers “who are you?” Signing in is authentication.
- Authorization answers “are you allowed to do this?” Checking that plan 43 belongs to you is authorization.
Maya's opening bugs include both. The leaky error message and the weak password hashing are authentication failures. /plan/43 is an authorization failure, and the more common one.
Authentication: don't invent it. If your system stores passwords, OWASP's guidance is specific. Hash them with a slow algorithm designed for passwords: Argon2id first (with at least 19 MiB of memory, two iterations, and one degree of parallelism), then scrypt, then bcrypt for older systems (OWASP, Password Storage Cheat Sheet). Return the same generic message whether the email or the password was wrong, check new passwords against lists of known breached passwords, limit failed attempts per account, and offer multi-factor authentication. OWASP cites Microsoft's analysis that MFA would have stopped 99.9% of account compromises (OWASP, Authentication Cheat Sheet). That's a long list to get right, which is why the decision box below hands most of it to a specialist service. Chapter 15 covers authentication in depth.
One rule applies whichever way you sign students in: issue a new session ID at sign-in. OWASP's session guidance requires renewing the session ID after any change in privilege, and names the move from anonymous to authenticated as the most common case (OWASP, Session Management Cheat Sheet). If the ID stays the same, someone who planted or learned it before sign-in is now signed in too.
Authorization: deny by default, check ownership in the query. OWASP's prevention advice for broken access control starts with denying access by default, except for public resources, and enforcing record ownership instead of letting any user act on any record (OWASP, 2021). Chapter 4 put the ownership check in the SQL for ticking a task. Do the same for every query that touches a student's data:
# backend/coach/plans/queries.py
GET_PLAN = """
SELECT id, exam_date, source FROM plans
WHERE id = %(plan_id)s AND user_id = %(user_id)s
"""
When the row isn't there, the route answers 404, whether the plan doesn't exist or belongs to someone else. A 403 would confirm that plan 43 exists. A useful habit in reviews: any query on a student's data without an owner condition is a bug until proven otherwise.
Data access layer and transactions#
Some actions take several writes, and they must happen together. Signing in is one of them: find or create the user, move the anonymous plan to the user, delete the anonymous session, create a new session. If the server crashes after moving the plan but before replacing the session, the data is left half-changed.
A transaction groups statements so they take effect all together or not at all. In PostgreSQL, you surround them with BEGIN and COMMIT, and if something goes wrong partway, ROLLBACK undoes everything since BEGIN. Any single statement outside such a block runs in its own implicit transaction (PostgreSQL, Transactions).
Who opens the transaction matters. Put it in the service, around one whole product action, not in each query function. Then a rule like “signing in keeps your plan” is either fully done or not done at all.
Migration management#
A migration is a script that changes the database's structure: adding a table, adding a column, changing a constraint. Migrations are stored in the repository in order, so every copy of the database (yours, staging, production) can be brought to the same shape.
With SQLAlchemy, the usual tool is Alembic. It keeps migration scripts in a versions/ folder, and each script has an upgrade function and a downgrade function to undo it (Alembic, Tutorial). Alembic can also autogenerate a migration by comparing the database with your model code. Its documentation is direct about the limits: autogenerate isn't intended to be perfect, and its candidate migrations must always be reviewed and corrected by hand (Alembic, Auto Generating Migrations). That applies doubly when an agent wrote the model change. Chapter 4 listed migrations as a risky zone that you read line by line.
Apply chapter 5's expand, migrate, contract pattern to the schema. Maya's accounts rebuild added plans.user_id as an optional column (expand), moved plans to users as students signed in (migrate), and left session_id in place, because anonymous plans still exist before sign-in. Nothing was dropped in the same release that added its replacement.
6.4 AI-assisted integration workflow#
Scaffold with a stable repository structure#
Agents follow the shape of the code they find. If the structure is consistent, a new agent session puts new code in the right place; if each module is laid out differently, every session invents a layout. Fix the layout early and write it in the project instructions file:
backend/coach/
main.py wires modules, error handler, routers
accounts/ routes.py service.py queries.py schemas.py
plans/ routes.py service.py queries.py schemas.py ports.py
ai/ adapters for the model provider (chapter 5)
backend/migrations/versions/
backend/tests/
web/src/
api/ generated types and one fetch helper
ui/ shared components
pages/ one folder per route
Implement vertical slices end to end#
There are two ways to build accounts with agents. Layer by layer: first all the tables, then all the endpoints, then all the screens. Or in vertical slices: one small feature at a time, from screen to database, working before the next starts.
Layer by layer looks efficient and fails late. Nobody can use anything until the last layer lands, and that's when the mismatches between layers appear, all at once. Vertical slices fail early and small. Maya's rebuild became five slices, in order:
- Sign in, keeping the anonymous plan (US-1).
- Plans readable only by their owner.
- Profile and time zone in settings.
- Progress from task activity (US-5).
- Delete account and data (US-6, NFR-6).
Each slice is small enough to review in one sitting and to describe to an agent in one prompt with its acceptance criteria.
Specify contracts before parallel generation#
Within a slice, you can split the work between agents, one on the screen and one on the endpoint, but only after the contract exists. The order:
- Write the contract table (chapter 5) and the Pydantic schemas.
- Generate the OpenAPI document and the TypeScript types from it.
- Commit both. Now give the frontend agent the generated types and the backend agent the schemas.
- Merge, regenerate types, and run the typecheck and the integration tests.
If an agent wants to change the contract partway, it stops and asks. It doesn't quietly change the schema to suit its side.
Run integration tests after each increment#
Unit tests check one piece in isolation. Integration tests check that pieces work together. Ham Vocke's practical test pyramid recommends many small, fast unit tests, fewer broader tests, and very few end-to-end tests. He describes narrow integration tests that each check one integration point, such as your code against a real database, run locally (Vocke, 2018).
For a full-stack slice, the most valuable integration test sends real HTTP requests to the app, backed by a real local PostgreSQL test database, and checks the result the way a student would experience it. FastAPI's TestClient lets pytest call the app directly without starting a server (FastAPI, Testing). Two rules make these tests catch the bugs that matter:
- Use two students. OWASP recommends access-control tests at the unit and integration level (OWASP, 2021). A test suite with only one user can't find
/plan/43. - Use values that aren't defaults. A test that saves the default time zone and reads it back proves nothing, as the failure clinic shows.
6.5 Product quality#
Loading, empty, and error states#
Every screen that shows server data has at least four states, and agents usually build only one of them, the one where everything is there:
| State | Learning Coach plan page |
|---|---|
| Loading | A short placeholder in the shape of the week, not a blank page |
| Empty | “No plan yet” and a button to make one |
| Error | What went wrong in plain words, and a Try again button; no stack traces |
| Ready | This week's tasks |
Add a fifth for signed-in features: signed out, when a session expires while the page is open. The shared PageState component handles the first three in one place. List all the states in the acceptance criteria and check each one; a quick way is to slow or block requests in the browser's developer tools and watch what the page does.
Usability and accessibility#
Run two short checks on every slice. First, a keyboard-only pass: unplug the mouse, sign in, make a plan, tick a task, and change a setting with only Tab, Enter, Space, and arrow keys. Every control should be reachable, and you should always see where the focus is. Second, a screen-reader spot check of each form error, using the TextField from section 6.1. These checks catch common failures; they don't prove WCAG 2.2 AA conformance. Before launch, have someone experienced in accessibility testing review the main flows.
Mobile responsiveness#
Maya's testers mostly open the coach on their phones, between classes. Check every slice at a narrow width in the browser's device view and on one real phone. Look for text that runs off the screen, buttons too small to tap, and forms covered by the on-screen keyboard. A real phone also reveals slow mobile networks, which the desktop view hides.
Basic performance profiling#
Google's Core Web Vitals give three measures of how a page feels, with “good” thresholds measured at the 75th percentile of page loads:
- Largest Contentful Paint (LCP) within 2.5 seconds: how quickly the main content appears.
- Interaction to Next Paint (INP) of 200 milliseconds or less: how quickly the page responds to a tap or click.
- Cumulative Layout Shift (CLS) of 0.1 or less: how much the layout jumps around while loading.
Chapter 3's NFR-3 asks for the plan page to show the current week within 2 seconds at the 95th percentile, which is stricter than the LCP threshold. When a page is slow, record it with the Performance panel in your browser's developer tools before asking an agent to “make it faster”. Common causes in agent-built apps are requests that wait for each other when they could run at once, and the same data fetched several times by different components. Give the agent the recording and the target, not the vague complaint.
Architect's decision: how should students sign in?#
| Context | Accounts must be rebuilt (opening scenario). About 200 early users expected (an assumption). Two people, neither a security specialist. US-1 asks for email sign-in; NFR-7 asks for an 18+ confirmation at sign-up. |
| Constraints | Little time for security maintenance. Student emails are personal data. The accounts module must own users and sessions, per ADR-001. |
| Option A | Fix the agent's password login: switch to Argon2id, generic errors, rate limits, a new session at sign-in, breached-password checks, password reset by email, and later MFA. Full control and no outside dependency, but every item on OWASP's list becomes Maya's to build, test, and maintain. |
| Option B | Use a well-maintained authentication library inside the FastAPI server, with passwords still stored in Maya's database. Less code to write than A, but she still owns password storage, reset emails, and breach response. |
| Option C | Use a hosted identity provider for the sign-in screens, password handling, email verification, and MFA. After the provider confirms who the student is, the accounts module looks up or creates the user, claims the anonymous plan, and issues its own session cookie. |
| Decision | Option C, recorded as ADR-002. |
| Tradeoffs | A monthly cost that grows with users, and a dependency that is hard to leave, because user identities live with the provider. Student emails are shared with a third party, which the privacy policy must say. The app still owns sessions and authorization, so /plan/43-style bugs remain Maya's to prevent. |
| Validation | The integration tests for the five slices pass with a fake provider. A security review before launch checks the sign-in callback, the session cookie settings, and the ownership checks. |
| Revisit trigger | Provider cost exceeds an agreed share of revenue, students need sign-in that the provider can't offer, or a data-residency requirement the provider can't meet. |
Whichever option you choose, sign-in is security-critical code. Have someone with security expertise review it before real users depend on it.
AI collaboration recipe#
Agents are fast at full-stack scaffolding and at writing tests once you name the cases. They are weak at keeping layers consistent and at imagining a second user. Give them the contract and the cases.
1. Turn a slice into a contract.
Here are user story US-1 and its acceptance criteria, ADR-001, and ADR-002. Without writing code, propose the contract for slice 1: each endpoint with input, output, error codes, and owner module; the Pydantic schemas; the database changes as an Alembic migration outline; and the frontend routes and components affected. List anything the acceptance criteria don't decide.
2. Build one side from the committed contract.
Implement the backend for slice 1 in
backend/coach/accounts/, using the schemas inaccounts/schemas.pyexactly as committed. Request models forbid extra fields. Every query on student data includes an owner condition. Sign-in runs in one transaction and issues a new session ID. If the contract seems wrong, stop and tell me instead of changing it.
3. Write the tests before reading the code.
Write pytest integration tests for slice 1 using
TestClientand the local test database. Use two students in every access test. Cover: the anonymous plan survives sign-in; the session ID changes at sign-in; one student can't read, change, or delete another's plan (expect 404); wrong email and wrong password give the same response. Use non-default values everywhere. Don't change application code.
4. Review across layers.
Compare the generated TypeScript types, the frontend code that calls these endpoints, and the backend schemas. List every field name, type, or error code that doesn't match exactly, and every frontend screen that lacks a loading, empty, or error state.
Boundaries to state every time:
- “Follow the folder layout in the project instructions. Use components from
web/src/ui/.” - “Don't edit generated types by hand; regenerate them.”
- “Don't edit an existing migration that has been applied; write a new one.”
- “Work on a new branch. Don't merge.”
Worked example: signing in without losing the plan#
Maya's first slice fixes the opening bug that testers noticed most: signing up made the plan disappear.
1. Acceptance criteria. She wrote them in the chapter 3 style before any code:
AC-1.1 Given an anonymous student with a plan, when she signs in for the
first time, then the same plan is shown on /plan.
AC-1.2 Given a signed-in student, when she signs in on a second device,
then she sees the same plan there.
AC-1.3 Given any sign-in, then the session cookie has a new value.
AC-1.4 Given an anonymous plan and an account that already has a plan,
when she signs in, then both are kept and /plan asks which to use.
AC-1.4 was the case the agent's contract proposal flagged as undecided. It was a product decision, so Maya made it.
2. The service, in one transaction. The accounts module owns users and sessions. Plans belong to plans, so accounts asks the plans module to claim them through its public function instead of writing the plans table itself:
# backend/coach/accounts/service.py
from coach.accounts import queries
from coach.plans import service as plans
def sign_in(conn, anonymous_session_id: str | None, identity) -> str:
"""Return a new session ID for the verified identity."""
with conn.transaction():
user_id = queries.find_or_create_user(conn, identity.subject, identity.email)
if anonymous_session_id:
plans.claim_session_plans(conn, anonymous_session_id, user_id)
queries.delete_session(conn, anonymous_session_id)
return queries.create_session(conn, user_id)
# backend/coach/plans/queries.py
CLAIM_SESSION_PLANS = """
UPDATE plans SET user_id = %(user_id)s, session_id = NULL
WHERE session_id = %(session_id)s AND user_id IS NULL
"""
conn.transaction() comes from psycopg, the PostgreSQL driver: it commits when the block finishes and rolls back if anything inside raises an error. queries.create_session generates a new random ID, so AC-1.3 holds by design.
3. The route. It verifies the identity provider's result, calls the service, and sets the cookie with the settings chapter 4 asked for:
# backend/coach/accounts/routes.py
@router.post("/session", status_code=204)
def finish_sign_in(body: SignInResult, request: Request, response: Response, conn=Depends(get_conn)):
identity = identity_provider.verify(body.token)
session_id = service.sign_in(conn, request.cookies.get("sid"), identity)
response.set_cookie("sid", session_id, httponly=True, secure=True, samesite="lax")
identity_provider is a port, like the plan generator in chapter 5. The real adapter checks the provider's signed result; a fake adapter accepts test tokens, so tests run without the provider.
4. Tests with two students. The test fixture builds the app with the fake identity provider and a local test database. Because the cookie is marked Secure, the test client uses an https base address so it sends the cookie back:
# backend/tests/test_accounts_slice1.py
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def new_browser(app_with_fakes):
return lambda: TestClient(app_with_fakes, base_url="https://testserver")
def test_anonymous_plan_survives_sign_in(new_browser):
maya = new_browser()
plan = maya.post("/api/plans", json=PLAN_INPUT).json()
anonymous_sid = maya.cookies["sid"]
sign_in(maya, "[email protected]")
assert maya.cookies["sid"] != anonymous_sid
assert maya.get(f"/api/plans/{plan['id']}").status_code == 200
def test_another_student_cannot_read_the_plan(new_browser):
maya, sam = new_browser(), new_browser()
sign_in(maya, "[email protected]")
sign_in(sam, "[email protected]")
plan = maya.post("/api/plans", json=PLAN_INPUT).json()
assert sam.get(f"/api/plans/{plan['id']}").status_code == 404
sign_in is a small helper that posts a fake provider token to /api/session. PLAN_INPUT uses a real-looking exam date and 12 weekly hours rather than any default. Maya ran the tests against the old agent-built code first: both failed, the first because the plan vanished and the second because Sam got 200. Against the rebuilt slice, both passed.
5. Check it as a student. On her phone, she made a plan without signing in, signed up, and saw the same plan. On her laptop, she signed in and saw it again. With the Network panel open, she confirmed that the sid cookie changed at sign-in and was marked HttpOnly and Secure. She read the migration line by line (it added plans.user_id, the check constraint, and an index on user_id) and opened the pull request with the acceptance criteria, the test results, and a note asking for a security review.
Verification: do the layers agree?#
| Check | How |
|---|---|
| Contract | Types are regenerated from the OpenAPI document and the frontend typechecks. No hand-written copies of API types exist. |
| Request schemas | Every request model forbids extra fields. Sending an unknown field returns 422 naming it. |
| Ownership | Every query on student data has an owner condition. Two-student tests exist for read, change, and delete. |
| Sign-in | A new session ID at sign-in; identical responses for wrong email and wrong password; cookie is HttpOnly and Secure. |
| Transactions | Multi-step actions run in one transaction in the service. A test that forces a failure halfway leaves no partial change. |
| Migrations | Each migration was read line by line, has a working downgrade, and was applied to a copy of the database before production. |
| Screen states | Loading, empty, error, ready, and signed-out states are checked for each new screen. |
| Quality | Keyboard-only pass, phone check, and a performance recording against NFR-3. |
Failure clinic: the time zone that was always UTC#
Symptom. After slice 3 shipped, students in Singapore reported that “this week” started on the wrong day, and Monday's tasks appeared on Sunday evening. Every one of them had set their time zone in settings, and the settings screen said “Saved”.
Reproduce. Maya set her time zone to Asia/Singapore, saved, and reloaded. The settings screen showed UTC. The request in the Network panel returned 200.
Diagnose. The slice had been built by two agents in parallel, before Maya adopted the contract-first order. The frontend agent sent:
{ "displayName": "Maya", "timeZone": "Asia/Singapore" }
The backend agent's schema expected display_name and time_zone, and had given time_zone a default of "UTC", while display_name was optional. Pydantic ignored the two unknown fields, as it does by default, filled in the defaults, and saved. Nothing failed. Each agent's own tests passed: the frontend tests mocked the server, and the backend test saved "UTC" and read back "UTC".
Fix. Three changes, one per gap:
- Request models forbid extra fields, so the misspelled request now returns 422 naming
displayNameas unexpected. - The frontend imports types generated from the OpenAPI document.
timeZoneno longer compiles, because the type saystime_zone. - A new integration test saves
Asia/Singaporethrough the API and reads it back.
She also wrote a small migration to mark every profile saved since the slice shipped, and the settings screen asked those students to confirm their time zone, because their saved value couldn't be trusted.
Lesson. Defaults and ignored fields turn a contract mismatch into silent wrong data. Decide the contract before agents work in parallel, make the build fail when the sides disagree, and test with values that aren't defaults.
Architect's checklist#
Before merging a full-stack slice:
- ☐ The slice has acceptance criteria, and every undecided case was decided by a person.
- ☐ The contract was committed before parallel work, and frontend types are generated from it.
- ☐ Request models forbid extra fields; errors use the one error shape.
- ☐ Routes are thin; rules live in services; queries live in the data access layer.
- ☐ Every query on student data checks ownership, and two-student tests prove it.
- ☐ Sign-in issues a new session ID, and error messages don't reveal which emails have accounts.
- ☐ Multi-step actions run in one transaction.
- ☐ Each migration was read line by line and has a tested downgrade.
- ☐ Server state has one copy in the frontend; nothing calculable is stored.
- ☐ Loading, empty, error, and signed-out states exist and were checked.
- ☐ Keyboard, phone, and performance checks were done.
- ☐ Security-sensitive changes are flagged for expert review.
Exercises#
Beginner. For the Meal Planner (an app that suggests a week of dinners from what's in your fridge and builds a shopping list), list the routes and, for three screens, describe the loading, empty, error, and ready states in one sentence each. Then mark each piece of data on those screens as server state or local state, and name one value that should be calculated rather than stored.
Intermediate. For the Trip Planner, build the vertical slice “save a trip” contract-first: write the contract table and Pydantic schemas, generate TypeScript types, and then ask one agent for the screen and another for the endpoint. Write integration tests with two travelers, including one who tries to open the other's trip, and use non-default values throughout. Record any mismatch the typecheck or the tests caught.
Advanced. For the Shop Help Chat, design the data model for customers, orders, and chat conversations as a Mermaid ER diagram, with an owner for every table. Implement “show my recent orders” as one slice, with the ownership check in the query. Then ask a fresh agent session to “let support staff see any customer's orders”. Review its change: does it keep deny-by-default, separate staff from customers on the server, and come with tests for both? Write down what you would require before merging.
Lab, artifact, and review#
Lab. Rebuild accounts in your Learning Coach as vertical slices: sign-in that keeps the anonymous plan, plans readable only by their owner, a profile with time zone, progress calculated from tasks and activity, and account deletion. Use real PostgreSQL persistence with Alembic migrations.
Deliverable. Add to your portfolio:
- the running full-stack application;
- the OpenAPI document and the TypeScript types generated from it;
- the database schema as an ER diagram, plus the migrations;
- integration tests with two students for every slice, including tests you saw fail before the fix;
- ADR-002 on how students sign in;
- a one-page quality report: screen states, keyboard and phone checks, and a performance recording against NFR-3.
Reflection#
- Which layer of your app would a second user break first? How do you know?
- Where does your frontend store something it could calculate, or keep a second copy of server data?
- Which of your request schemas would silently accept a misspelled field today?
- If an agent built your next slice layer by layer instead of end to end, when would you first find out something was wrong?
In chapter 7 you will look more closely at the data itself: choosing where each kind of data should live (database tables, files, events), and keeping it correct and private as the product grows.
References#
- Alembic. Auto Generating Migrations. Alembic documentation.
- Alembic. Tutorial. Alembic documentation.
- FastAPI. Background Tasks. FastAPI documentation.
- FastAPI. Bigger Applications: Multiple Files. FastAPI documentation.
- FastAPI. Testing. FastAPI documentation.
- openapi-typescript. Introduction.
- OWASP. (2021). A01:2021 Broken Access Control. OWASP Top 10.
- OWASP. Authentication Cheat Sheet. OWASP Cheat Sheet Series.
- OWASP. Password Storage Cheat Sheet. OWASP Cheat Sheet Series.
- OWASP. Session Management Cheat Sheet. OWASP Cheat Sheet Series.
- PostgreSQL Global Development Group. Transactions. PostgreSQL documentation.
- Pydantic. Models: Extra data. Pydantic documentation.
- React. Choosing the State Structure. React documentation.
- Vocke, H. (2018). The Practical Test Pyramid. martinfowler.com.
- Walton, P. Web Vitals. web.dev.
- W3C Web Accessibility Initiative. Understanding Success Criterion 3.3.1: Error Identification. WCAG 2.2 Understanding documents.
Links checked October 2026.
Check your understanding
3 questions
1.Sam can open Maya's study plan by changing the plan ID in the address bar. Where does the fix belong?
2.When a student signs in, the server moves her anonymous plan to her account, deletes the anonymous session, and creates a new one. Why run all three steps in one database transaction?
3.You want one agent to build the settings screen while another builds the settings endpoint. What should exist before either starts?
Was this chapter helpful?
Cite this chapter
Shi, T. (2026). AI-Assisted Full-Stack Application Development. In Path to AI Architect: From vibe coding to designing, building, and leading AI-driven software systems (Online edition, Chapter 6). Exciton Research. Retrieved November 2, 2026, from https://excitonresearch.com/education/become-an-ai-architect/ai-assisted-full-stack-development
Brief quotations are welcome with attribution and a link.
© 2026 Tian Shi. All rights reserved.