Skip to content

Architecture

Intro named three guardrails. This section shows how those three look in code. The FSD six-layer dependency direction, the SSOT seats for routes, the API adapter, and RLS policies, and then the three-step Stripe split. In every case, the build or the migration step blocks the AI from cutting across boundaries on a whim.

The fact that src/ (the client SPA) and api/ (the server) are not connected by imports sits in the same section. The only thing that flows between them is types, and the seat for those types is one file — src/shared/api/server/types.ts.

Dependencies flow top to bottom. Importing against an arrow breaks the build.

graph TB
app[app — main.tsx, providers, router]
pages[pages — landing, pricing, auth, app, checkout-result]
widgets[widgets — site-header, pricing-grid, app-shell]
features[features — sign-in, checkout, manage-subscription, require-auth]
entities[entities — User, Subscription, Plan, Invoice]
shared[shared — UI kit, lib, api, config]
app --> pages
pages --> widgets
widgets --> features
features --> entities
entities --> shared

Each layer has a clear seat.

  • shared: domain-agnostic utilities. The Supabase client, the API adapter, route constants, env validation, the UI kit, form helpers. Imported anywhere; depends only on itself.
  • entities: domain models. User, Subscription, Plan, Invoice, Organization. Only the skeleton sits in the base; the entities of your SaaS get filled in during phase 5.
  • features: one user action per slice. sign-in, sign-up, checkout, manage-subscription, require-auth.
  • widgets: blocks that compose several features. site-header, site-footer, pricing-grid, app-shell.
  • pages: entry-point screens by route. Marketing pages, auth pages, the protected dashboard, checkout success and cancel.
  • app: entry-point glue. createRoot in main.tsx, RouterProvider in App.tsx, the provider tree.

eslint-plugin-boundaries enforces those arrows. Reverse imports are blocked, and so is reaching across slices within the same layer. features/sign-in cannot import features/checkout directly — the build refuses. The only way into a slice from the outside is through its index.ts.

jsx-a11y rides on the same lint pass and catches accessibility violations alongside. Missing <img alt>, missing <label htmlFor>, a <div> that only takes clicks. Interactive elements that keyboard-only navigation cannot reach get caught right there. The build breaks, so the model falls in line before a reviewer ever has to.

How to run the checks:

Terminal window
pnpm lint # FSD boundaries + jsx-a11y in one pass
npx eslint src --ext .ts,.tsx # direct call

When you hand code to an AI and let the model pick which layer it goes into, nine times out of ten it will blur features and widgets together. Naming the slice for the model is safer. Something like “add a Customer Portal call action to features/manage-subscription.”

The reasoning behind this direction lives in docs/adr/0001-fsd-import-direction.md. Code carries the what; the ADR carries the why.

src/ goes into the Vite SPA bundle. api/ runs on Deno (Edge Functions) or Node (Hono adapter). They are different runtimes, and importing one from the other either breaks the bundle or leaks a secret.

src/ ← Vite SPA, goes to the browser
└── shared/api/server/types.ts ← request and response interface (shared)
api/ ← Deno or Node, stays on the server
├── functions/
└── migrations/

For server code to stay out of the client bundle, the two trees cannot be connected by imports. The one thing they can share is types, and that lives at src/shared/api/server/types.ts. The api/ side reads from that location through a relative import or a path mapping.

If src/ ever imports SUPABASE_SERVICE_ROLE_KEY, the reviewer blocks it. That key bypasses RLS, so if it leaks into the client bundle, read and write across every row opens up. Calls that genuinely need service_role go inside api/functions/<name>/index.ts and reach for it through Deno.env.get('SUPABASE_SERVICE_ROLE_KEY').

Every route path is defined as a const in src/shared/lib/routing/routes.ts. The router tree, <Link to=...>, and navigate(...) all import that constant. Sprinkling raw path strings across the code creates exactly the spot where changing one path forces you to chase down five.

export const ROUTES = {
landing: '/',
pricing: '/pricing',
login: '/login',
signup: '/signup',
app: { root: '/app', settings: '/app/settings', billing: '/app/billing' },
checkout: { success: '/checkout/success', cancel: '/checkout/cancel' },
} as const;

The routing mode that fits your SaaS gets picked in phase 4 (/4-design-saas). The decision lands in .claude/state/routing-config.json. Four shapes — marketing-app (public plus protected split), app-only (login gate as the first screen), multi-tenant (/o/:slug/*), and docs-heavy (lots of content pages). On top of that decision, phase 5 generates the src/pages/ tree and the ROUTES const together.

The const above is just the base example. Your route tree is filled in by phase 5, so right after forking the base you will not see your own domain paths yet. This is one of the spots that fills in when you fork.

src/shared/api/server/ holds one adapter interface and two implementations.

src/shared/api/server/
├── index.ts public entry, createApiAdapter factory
├── types.ts ApiAdapter interface, request and response types
├── edge-functions.ts supabase.functions.invoke wrapper (default)
└── hono.ts fetch('/api/...') wrapper (optional)

The caller (a feature or a widget) never knows which adapter is in play — it just calls api.invoke('checkout-session', payload). The adapter decision comes from import.meta.env.VITE_API_MODE (build-time) or .claude/state/api-adapter.json (the phase 4 decision seat). The factory reads that value and picks an instance.

The reason this adapter exists is so UI work can start before the backend runtime is decided. In the first sprint you can run on Edge Functions; later, if the decision moves to Cloudflare, you flip on the Hono adapter and never touch the calling code.

src/shared/api/server does not import entities. The mapping between an entity and an API request type belongs to features/<action>. The adapter only sees the transport layer; the domain stays in features.

src/shared/api/supabase/client.ts calls createClient<Database>(url, anonKey) exactly once. Calling createClient in another file forks the session and the realtime channel. The class of bug where one user’s session ends up split across two instances starts right there.

import { createClient } from '@supabase/supabase-js';
import { env } from '@/shared/config/env';
import type { Database } from './database.types';
export const supabase = createClient<Database>(env.SUPABASE_URL, env.SUPABASE_ANON_KEY);

The Database type is never hand-written. The command supabase gen types typescript --linked > src/shared/api/supabase/database.types.ts pulls the type from the current migration state and drops it in. Every new migration means running the same command again.

Every RLS policy gets written into api/migrations/<timestamp>_<name>.sql. Policies are never built through Supabase Studio’s UI. UI-built policies do not show up in git, and they are how drift sneaks in between production and staging.

The exact shape depends on your domain entities, but the form is similar. Restrict select to a user’s own rows; allow write only through service_role.

alter table public.subscriptions enable row level security;
create policy "subscriptions_select_own"
on public.subscriptions for select to authenticated
using (auth.uid() = user_id);

The migrations that ship with the base are only a skeleton. Your tables and policies get filled in during the implement-data-layer step in phase 5. This is one of the spots that fills in when you fork.

Verification belongs to supabase test db. If you write unit tests in api/policies.test.sql along the lines of “this user sending this query should be rejected,” you can confirm the policies in the migration actually behave the way you intended.

Payments split into start, manage, and receive. The urge to cram all three into one function gets cut here, which keeps the webhook signature check from blending into anything else and lets it sit inside its own module.

sequenceDiagram
participant UI as features/checkout
participant CSF as Edge Function checkout-session
participant STRIPE as Stripe
participant WH as api/functions/stripe-webhook
participant DB as subscriptions table
UI->>CSF: user picks the Pro plan
CSF->>STRIPE: stripe.checkout.sessions.create
STRIPE-->>CSF: session.url
CSF-->>UI: { url }
UI->>STRIPE: window.location.assign(url)
STRIPE-->>UI: redirect to /checkout/success after payment
STRIPE->>WH: webhook POST + signature
WH->>WH: stripe.webhooks.constructEvent (signature check)
WH->>DB: subscriptions upsert (status, plan, current_period_end)

The three seats have separate responsibilities.

  • Start lives in features/checkout. It calls the checkout-session Edge Function and hands the returned URL to window.location.assign(url). The only Stripe credential exposed to the client is the publishable key (pk_...).
  • Manage lives in features/manage-subscription. It calls the customer-portal Edge Function and sends the user to the URL it returns. Plan changes, payment method changes, and cancellation all happen inside Customer Portal.
  • Receive lives in api/functions/stripe-webhook. The first line of that handler is stripe.webhooks.constructEvent(body, sig, secret). Touching the database with an event that skipped signature verification is how a forged webhook activates an arbitrary subscription. Missing signature verification is a hard reviewer block.

Depending on the service, invoices and customers tables might need webhook-driven sync alongside subscriptions. Which tables get updated by which event is settled during the data-schema decision in phase 4. This is one of the spots that fills in when you fork.

src/shared/config/env.ts validates the env once with zod and then exports it. Missing values fail the build immediately. The class of “undefined at runtime” bugs that usually shows up after deploy gets pulled forward into build time.

import { z } from 'zod';
const Env = z.object({
VITE_SUPABASE_URL: z.string().url(),
VITE_SUPABASE_ANON_KEY: z.string().min(20),
VITE_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
VITE_API_MODE: z.enum(['edge-functions', 'hono']).default('edge-functions'),
});
export const env = Env.parse(import.meta.env);

The VITE_ prefix is the point. Vite only exposes variables prefixed with VITE_* to the client bundle. Variables without the prefix, like SUPABASE_SERVICE_ROLE_KEY, come up undefined on the client and get caught at build time. The exposure boundary is baked into the variable name itself.

Three Claude Code hooks ship in .claude/hooks/, and the hooks field in .claude/settings.json wires them up. They cover the spots where context tends to fall apart even when neither the user nor the AI is paying attention to a command.

ScriptEventResponsibility
session-start.shSessionStartPrepends active.md, the last 30 lines of progress.md, feature_list.json, the first 50 lines of saas-spec.md, and MEMORY.md to stdout
stop-reminder.shStopPrints a one-line nudge to the user when active.md holds fewer than 20 characters
pre-commit-check.shPreToolUse (Bash matcher)When tool_input.command matches git commit, runs pnpm run typecheck && pnpm run lint. On failure, exits 2 and pipes the last 30 lines of the failing log to stdout so the AI sees the block reason

The Bash matcher in PreToolUse extracts tool_input.command from the JSON on stdin via jq (or sed as fallback) and only routes a git commit match into the typecheck gate. Other Bash commands pass through with exit 0 so the rest of the workflow does not pick up extra friction.

What session-start.sh prepends carries the phase 1 through 6 state directly: active.md (the next task), the passes field in feature_list.json (how far you got), saas-spec.md (the concept, the scenarios, the data model). Even after /clear and a fresh session, getting those five seats prepended is enough to keep the work from breaking.

To add a new hook, write .claude/hooks/<name>.sh (start with #!/usr/bin/env bash, mark it executable), register it under the right event in settings.json, and add a row to .claude/hooks/README.md. Each hook is independent, so removing one does not affect the other two.

For the same system written at a slower, non-developer pace, see chapter 07 of the beginner guide.

Where the hooks act in the background, two slash commands cover the spots a user has to enter on purpose. Both are wired by skills under .claude/skills/.

CommandWhen it fitsCore behavior
/recover-from-blockedRight after review-saas writes blocked because two attempts in a row failedPulls the dimensions scoring below 3 from the latest .claude/state/review-*.md, compares the last two iterations of the relevant builder-*.md to flag same-mistake repeats, then prints a plain-language readout and offers three options (rollback / retry-with-help / manual)
/resumeAfter /clear, or coming back after a few daysSynthesizes a stage from state files and the recent git log (empty → paced → analyzed → designed → customized → implemented → built → deployed). Where session-start.sh prepends raw context, this command hands a one-screen synthesized summary back to the user

Option A on /recover-from-blocked calls git reset --hard, so two blocks sit in the way on purpose: an AskUserQuestion step plus a literal yes typed in afterward. If the working tree is dirty, an extra line warns that those changes will go too.

/resume halts synthesis whenever active.md and the detected stage disagree, asking the user which one is correct. Work happens on multiple machines, or active.md does not get updated after finishing — an explicit prompt costs less than a wrong synthesized answer.

For the same two commands written at the slower beginner pace, see chapter 06 of the beginner guide.

The routing mode, the API adapter, monthly versus annual pricing, whether the data schema goes multi-tenant. Decisions that are expensive to undo all gather inside phase 4. Each of those gets a page in docs/adr/.

One ADR per decision. The title follows 0001-routing-mode-marketing-app.md, with a sequence number and a slug for the decision. The body is four paragraphs: context, decision, alternatives, consequences. The template lives in docs/adr/README.md, and one ADR drops automatically every time phase 4 settles a decision.

.claude/agent-memory/<title>.md is the AI’s cache of the same decision. The ADR is the SSOT humans read; agent-memory is what gets stitched back into AI context. If the two ever disagree, trust the ADR.

Verification comes next. How /test-saas turns saas-spec.md §2 scenarios into Vitest, Playwright, and supabase test db calls, the seat where that raw output gets re-synthesized into scenario language, and the six static dimensions plus security baseline S1 through S8 that review-saas checks.