Ask an agent to "design the architecture for a client portal SaaS" and you will get something impressive: an event bus, a read-model cache, a microservice split, maybe a mention of CQRS. It compiles in prose. It is also a design for a company with forty engineers, produced because the training data is full of architecture posts written by companies with forty engineers explaining the systems their scale forced on them. Nothing in that output is wrong; all of it is wrong for you. Systems design is the lesson where you must hold the pen, because the agent's default taste in architecture is calibrated to the wrong universe.
Design many, choose one
The failure mode of solo design is falling in love with the first architecture you sketch. The discipline is to hold the design conversation in a fixed order, generating options at each step before choosing, and to write down why the losers lost. The order:
Requirements, from the brief. Not features: invariants. From Briefcase's brief (4.1): a client must never see another client's project, an approval must be an immutable audit record, billing state must survive anything the UI does, and one operator must be able to run the whole thing. Each invariant will pin part of the architecture. Requirements that do not trace to the brief are decoration.
The load reality check. Do the arithmetic before choosing anything. Briefcase at an optimistic year one: 200 agencies, maybe 30 concurrent users at peak, a few thousand writes per hour, file uploads in the tens of megabytes. That is zero load. A single Postgres instance handles this with three orders of magnitude to spare. Here is the position, stated plainly: almost every idea that passes the 4.1 filter needs boring architecture, and the numbers are how you prove it to yourself. Exciting architecture at boring scale is pure cost: more failure domains, more deploy surface, more places for an agent-built bug to hide. You earn complexity with traffic, and traffic arrives with plenty of warning.
Data flow, drawn. Trace each core action from click to durable state: an upload, a comment, an approval, a subscription payment. Where does the data rest, what transforms it, what has to be true afterward. The drawing exposes the pieces you actually need, which for Briefcase is: a web app, a relational database with row-level tenancy enforcement, file storage, a payment provider whose webhooks mutate billing state, and something that runs work outside the request cycle (the digest emails). Nothing else showed up in any trace, so nothing else goes in the design.
Failure domains. For each piece: what happens when it is down, and what is it allowed to take with it? The clarifying Briefcase example is billing: Stripe's webhook delivery and your app can fail independently, so billing truth cannot live in the request/redirect path that the user's browser drives; it has to be reconstructed from events (4.7 builds exactly this). Same lens on background work: a digest email that fails at 2am must not lose the fact that it was owed (4.8's jobs table exists because of this sentence).
Build vs buy, per component. Not one decision, one per piece, and the rule for a solo operator is: buy anything that is undifferentiated and dangerous to get wrong (auth, payments, file storage, email delivery), build anything that is the product's actual value (the approval workflow, the tenancy model, the digest logic). Buying auth is not a shortcut, it is risk transfer. Building your own session handling in 2026 is choosing to compete with security teams instead of shipping.
Monolith vs services, argued honestly
At SaaS scale, meaning yours, the monolith wins and it is not close. The honest version of the argument, since a position without the counter-case teaches nothing: services buy you independent deploys, independent scaling, and failure isolation, at the price of network boundaries where function calls used to be, distributed debugging, and contract management between parts. Every one of those purchases is worthless below the scale where the pain they solve exists. You have one deployer (you), one scaling unit that is nowhere near its ceiling, and failure isolation needs at the domain level (billing vs app) that process boundaries do not respect anyway.
There is one service-shaped decision that does pay at solo scale: keeping truly async work (jobs, webhooks) behind an internal boundary so it can be reasoned about and tested separately, even while it deploys as part of the same Next.js app. Boundary yes, network no.
There is also an agent-era reason to prefer the monolith that predates the usual arguments: a monolith fits in one repo, one CLAUDE.md, one harness. Your dispatches, gates, and reviews from Part 3 all assume a single coherent codebase. Splitting services multiplies harness overhead by the service count, and you are the harness.
The reference architecture
This is the design 4.3 through 4.10 build, and the figure is the contract: Next.js on Vercel serving both pages and API route handlers; Supabase carrying auth, Postgres (with row level security as the tenancy wall), and file storage; Stripe outside the trust boundary, feeding billing truth in through a verified webhook; and a jobs table inside Postgres, worked by a route handler that Vercel Cron wakes up. Two properties do most of the work. First, every arrow that mutates tenant data passes through Postgres with RLS enforcing the who-sees-what invariant at the database, so an application bug (or an agent-written route with a missing check) fails closed. Second, both external event sources (Stripe, cron) land in tables (subscriptions via webhook_events, jobs) before anything acts on them, so the async half of the system is inspectable and replayable with SQL.
Sparring with an agent, for real
The dispatch pattern for design review is propose, attack, revise: you write the proposal, the agent attacks it from named angles, you revise or defend, and the residue becomes ADRs. The prompt that makes it work forbids agreement:
You are attacking a system design, not improving it. Rules: do not propose your own architecture. Do not agree with anything. For each attack, name the failure scenario concretely (who is doing what when it breaks) and the cheapest change that defuses it.
Attack from these angles, one at a time:
- Tenancy: how does agency A read agency B's data?
- Billing: how do the app's belief and Stripe's belief diverge?
- Async: what work is lost or duplicated when a function dies mid-run?
- Load: at what specific number does a component fall over first?
- Operations: what breaks at 2am that a solo operator cannot fix from a phone?
I will defend or revise after each. Sharpest attack first.
[paste architecture description + data flow]
Here is a real exchange from designing exactly this system, kept short but not sanitized, because the writing gate for this lesson is that the disagreement be real:
Attack (async, angle 3): "Your jobs design is a Postgres table polled by a cron-triggered function. Vercel cron minimum practical cadence plus polling means a file uploaded at 9:00:01 might not be processed until the next tick. For notification digests nobody cares. For file processing, the user is staring at an unprocessed upload. Also, your worker runs inside a function with a max duration; a batch that exceeds it dies mid-run, and your table now has running rows that nothing owns."
Defense and revision: The latency attack is right and the design changed: file-processing jobs are still written to the jobs table (durability), but the upload route handler immediately invokes the worker endpoint after enqueueing, so processing starts in seconds; cron becomes the sweeper that catches anything the direct kick missed. The mid-run death attack is also right: the worker claims jobs by setting running with a claim timestamp, processes with a time budget below the function's maxDuration, and the sweeper re-queues running rows whose claim is older than the budget. Both fixes are in 4.8's reference design because of this exchange.
Attack (tenancy, angle 1): "RLS on a shared schema means one policy mistake anywhere is a cross-tenant leak. Per-tenant schemas make the blast radius structural. You are choosing convenience over isolation."
Defense, held this time: Per-tenant schemas at 200 tenants means 200x migration fan-out, no easy cross-tenant admin queries, and connection and caching complications, an ops bill paid forever by one operator. The leak risk is real but testable: RLS policies get a dedicated test suite that attacks them from a wrong-tenant session (4.5), and that suite is a merge gate. Concession extracted by the attack: every tenant-scoped table carries org_id directly (denormalized) so policies stay one-hop and auditable, and no table ships without its policy test.
That is what sparring is for. The agent did not design the system; it broke two pieces of it, one attack changed the design, one hardened the argument for keeping it. Both outcomes are wins, and both got written down.
ADRs: the design's memory
An Architecture Decision Record is a short dated note: context, decision, alternatives considered, consequences. It is STATUS.md's "Decisions" section (3.1) grown up. ADRs exist because six months from now, you (or an agent reading your repo) will look at the jobs table and think "this should obviously be a queue service", and the ADR is what stops the relitigating. Briefcase's first three:
# ADR-001: Shared schema, org_id on every row, RLS enforced
Date: 2026-07. Status: accepted.
## Context
Briefcase is multi-tenant (agencies) with a hard invariant: no
cross-tenant reads, ever. One operator runs the system.
## Decision
Single Postgres schema. Every tenant-scoped table carries org_id.
Tenancy is enforced by RLS policies at the database, tested by a
policy suite that attacks from wrong-tenant sessions (4.5).
App-layer checks are additional, never sufficient.
## Alternatives
- Per-tenant schema/database: structural isolation, but migration
fan-out and ops cost scale with tenant count; wrong trade for one
operator. Revisit only for an enterprise tier that pays for it.
- App-layer-only checks: one forgotten WHERE clause from a leak;
rejected outright, this is where agent-built SaaS most often
leaks.
## Consequences
Policy tests are a permanent merge gate. Every new table needs
org_id, policies, and tests before it ships. Cross-tenant admin
queries stay easy (service role).# ADR-002: Next.js on Vercel + Supabase + Stripe
Date: 2026-07. Status: accepted.
## Context
Build vs buy per component, one operator. Undifferentiated
components that are dangerous to get wrong should be bought (auth,
payments, storage, email delivery); the product's value is the
approval workflow.
## Decision
Next.js (App Router) deployed on Vercel. Supabase for Postgres,
auth, and file storage (one vendor, one dashboard, RLS native).
Stripe for billing via Checkout + customer portal + webhooks.
## Alternatives
- Hand-rolled auth on managed Postgres: competes with security
teams instead of shipping; rejected.
- Separate best-of-breed vendors per component: more capability,
more integration surface and more dashboards than one operator
should carry at v1.
## Consequences
Vendor coupling accepted knowingly: auth and storage migrations are
real work later. RLS becomes the tenancy mechanism (ADR-001 depends
on this). Function duration limits shape job design (ADR-003).# ADR-003: Jobs in Postgres, worked by a cron-swept endpoint
Date: 2026-07. Status: accepted.
## Context
Async work exists (digest emails, file processing) and must survive
function death. Vercel functions have bounded duration; platform
queue products exist, as do vendors (Inngest, Trigger.dev).
## Decision
A jobs table in Postgres: idempotency key, status, attempts,
run_after. Producers enqueue; a worker route handler processes with
a time budget; Vercel Cron sweeps on schedule. Latency-sensitive
producers (file upload) kick the worker directly after enqueue;
cron catches what the kick missed and re-queues stale running rows.
## Alternatives
- Queue vendor (Inngest / Trigger.dev): better at fan-out, long
chains, and scheduling; adds a vendor, a dev server, and a second
place where system state lives. Not earned by two job types.
- Vercel Queues: in Beta as of July 2026; not building
billing-adjacent infrastructure on a beta primitive.
- Fire-and-forget in request handlers: loses work on function
death; violates the failure-domain requirement outright.
## Consequences
Job state is inspectable with SQL and testable in the same suites
as everything else (4.8's lifecycle test). Accepted ceiling:
high-frequency or fan-out-heavy workloads would outgrow this; the
ADR gets revisited at that point, with this file as the context.Where it breaks
The classic failure is architecture as aspiration: designing for the scale you hope to have, paying the complexity cost now for a payoff that only arrives if everything else goes right. The numbers from the load reality check are the antidote; keep them in the design doc so the aspiration has to argue with arithmetic.
The second failure is sparring theater: prompting the agent in a way that produces agreement (starting from "review my design" without forbidding approval), then recording the nodding as validation. You built a sycophancy machine (1.5) and pointed it at your architecture. The sparring prompt above works because attack is mandatory, agreement is forbidden, and every attack must name a concrete failure scenario, which makes empty criticism as hard as empty praise.
The third is the unwritten decision. A choice that lives only in the code gets relitigated by every future session, agent and human alike, and redesigned by accident during refactors. If a decision would cost more than an hour to reverse, it gets an ADR. Writing them takes minutes; not writing them costs the same argument over and over, forever.
Lablab-4-2Design Briefcase with a sparring partner
Goal: Produce your own Briefcase architecture through the propose-attack-revise loop, record three ADRs, then diff your design against the reference.
Prereqs: the Briefcase brief from 4.1 (in the lesson text), a Claude Code session, your briefcase-notes folder from lab 4.1.
- Without looking back at the reference architecture section, write your own proposal in
design.md: the components, the data flow for upload / comment / approval / payment, and your load arithmetic for year one. Twenty minutes, prose and a rough diagram description. It will be wrong somewhere; that is the material. - Start a fresh session and run the sparring dispatch from this lesson verbatim, pasting your
design.md. Take the attacks one at a time: for each, write DEFENDED (with your argument) or REVISED (with the change) directly intodesign.mdbefore moving to the next attack. - Demand concreteness. If any attack is generic ("consider scalability"), reject it and ask for the specific failure scenario with actors and numbers. You are training your own standard for what counts as a design argument.
- Write three ADRs in
adr/001throughadr/003for your three most expensive-to-reverse decisions, using the context / decision / alternatives / consequences shape from this lesson. At least one ADR must record a decision the sparring changed. - Now diff against the reference: compare your
design.mdwith this lesson's architecture and ADRs. For each difference, decide deliberately: adopt the reference (note why yours lost) or keep yours (note why, in the ADR). For the rest of the track, build on the reference architecture so the dispatches match; keep your variants recorded for your own product.
Verify
design.mdcontains load arithmetic with actual numbers, all five attack angles with a DEFENDED or REVISED verdict each, and at least one REVISED.- Three ADRs exist, each naming at least one rejected alternative and one accepted consequence (a cost, not just benefits).
- Your diff-against-reference notes exist and every difference has a deliberate keep-or-adopt decision. Zero differences is a red flag that you peeked; the point was to generate independently and reconcile.
>Troubleshooting
- The agent attacks everything equally, including things that are fine: ask it to rank its five attacks by expected cost of being wrong and defend only the top two in depth. Uniform severity is a model tell (1.5); ranking forces a position.
- The sparring session drifted into redesigning your system: your rules line got buried. Restate "do not propose your own architecture" and restart the angle. The value is in attacks on YOUR design, not in its design.
- Your ADRs read like documentation of the obvious: you picked decisions that were never live questions. An ADR earns its place when a smart person could have chosen otherwise; pick the three where the sparring was hottest.
Knowledge check
Knowledge check
Sources
- Vercel Functions maximum duration (defaults and limits that shape the worker's time budget in ADR-003): https://vercel.com/docs/functions/configuring-functions/duration (fetched July 2026)
- Vercel Cron Jobs (GET-to-path trigger model referenced in the jobs design): https://vercel.com/docs/cron-jobs (fetched July 2026)
- Vercel Queues in Beta as of this writing (ADR-003's reason for declining it): https://vercel.com/docs/queues (fetched July 2026)
- Supabase Row Level Security (the tenancy mechanism ADR-001 commits to): https://supabase.com/docs/guides/database/postgres/row-level-security (fetched July 2026)
- Best practices for Claude Code (spec and verification discipline the sparring dispatch instantiates): https://code.claude.com/docs/en/best-practices (fetched July 2026)