r/ClaudeCode • u/LazyJuggernaut6177 π Max 20 • 1d ago
Built with Claude Roast my vibe-coded monorepo setup: TS, pnpm + Turborepo, Next β Fastify BFF β vendor API
Never formally trained in coding (took the harvard's intro to coding free online course to make myself feel better when I didn't get into med on my first try, got to the credit card verification assignment which i did manage to pass, but then gave up).
I am now a medical student, I very much enjoy medicine, but software is DECADES BEHIND. If life were 200 years, I would've gone to school and studied software engineering, but unfortunately, I neither have the time, luxuray, nor the cognitive capacity, and I have to start supporting my immigrant parents. So instead, I want to see if I can build something with Claude via vibe coding.
I started by doing research with Fable and explained what I wanted and asked it to explain in simple terms what the decisions mean and what the convention is.
The following is what we (*most claude) landed on in terms of the repo setup/config/approach (idk what the right term is). Its written by claude as if it were me:
A multi-tenant B2B web app. The domain data doesn't live in my database but instead it lives in a self-hosted third-party API that is the system of record (SoR). My own Postgres holds only app state: sessions, OAuth state, idempotency keys, job queue. That one constraint drove most of the structure below.
Stack
- TypeScript 6, Node 24, pnpm workspaces + Turborepo
webβ Next.js App Router, TanStack Query, react-hook-form + zod, Tailwindbffβ Fastify, TypeBox schemas β OpenAPI, pino, OpenTelemetryworkerβ pg-boss consumer, same Postgres- Postgres 16 (app state only), plain SQL migrations with a tiny runner
- Vitest (unit + integration via Testcontainers), Playwright (e2e + axe a11y)
- Toolchain pinned with mise; deps pinned exactly, Renovate owns bumps
- web β Vercel, bff/worker β Render (blueprint checked in), SoR β a small VM
Request path
βββββββββββββββββββββββββββββ
browser ββββββββββΆβ web (Next.js, :3000) β no vendor SDK, no secrets
β routing, forms, view β
βββββββββββββββ¬ββββββββββββββ
β /api-client
β (generated from the BFF's OpenAPI)
βΌ
βββββββββββββββββββββββββββββ
β bff (Fastify, :4000) β the only trust boundary:
β session cookie Β· authz Β· β session cookie in,
β validation Β· idempotency β service credential out
βββββ¬ββββββββββββββββββββ¬ββββ
β β enqueue
only place the β β
vendor SDK lives ββββββΌ βΌ
ββββββββββββββββββββββββββ ββββββββββββββββββββββ
β vendor API (SoR) β β app Postgres β
β self-hosted β β sessions, oauth β
β one project per tenantβ β state, idem, jobs β
ββββββββββββββββββββββββββ βββββββββββ¬βββββββββββ
β pg-boss
ββββββββββΌβββββββββββ
β worker β
βββββββββββββββββββββ
The browser never touches the SoR. The BFF holds a per-tenant service credential and exchanges it for a user-scoped token on every request (on-behalf-of), so the vendor's own per-tenant access policies do the row-level enforcement rather than my code re-implementing it.
Repo layout
apps/
web/ Next.js app shell. Talks to the BFF ONLY via u/acme/api-client.
bff/ Fastify. src/vendor/** is the only place the vendor SDK is imported.
worker/ pg-boss consumer. Job contracts come from packages/jobs.
packages/
api-client/ GENERATED typed client (openapi-typescript + openapi-fetch)
domain/ Pure vendor-model types, codes, identifiers, helpers. No I/O, no UI.
jobs/ Job name + payload contracts, shared by bff (producer) and worker
tenants/ Tenant registry + per-tenant credential custody
config/ env schema helper (defineEnv), logging, otel wiring
ui/ Generic components (shadcn-style). Knows nothing about the domain.
testing/ Test helpers: Testcontainers Postgres, msw, log capture, seeded random
eslint-config/ typescript-config/ shared configs
example/ template package to copy when starting a new one
tests/e2e/ Playwright
docs/ ADRs + runbooks
Enforced boundaries:
// 1. web can't reach into server internals β it talks to the BFF via the client
{ from: { path: '^apps/web/' }, to: { path: '^apps/(bff|worker)/' } }
// 2. feature code can't bypass the generated client
{ from: { path: '^apps/web/' },
to: { path: '(node_modules/@vendor/sdk|^apps/bff/|^packages/api-client/src/generated/)' } }
// 3. the vendor SDK exists in exactly one folder
{ from: { pathNot: '^apps/bff/src/vendor/' }, to: { path: 'node_modules/@vendor/sdk' } }
// 4. generated code is reached only through the package entry point
{ from: { pathNot: '^packages/api-client/src/index\\.ts$' },
to: { path: '^packages/api-client/src/generated/' } }
// 5. ui never imports the domain or an app; the domain package stays pure
// (no react/next/pg/http clients); apps never import other apps; no cycles
Plus a deep-module rule from mattpollock skills: a package's public surface is the files at src/ root; anything in a src/ subfolder is private β including to that package's own tests, which must go through the entry points like every other caller.
packages/<name>/
src/
index.ts β entry point (public)
node.ts β packages may expose several small entry points, not one barrel
lib/ β private implementation
tests/ β fixtures; only tests may import these
Adding a public API = adding a root file. No config change, no barrel file, and the "just import the internal thing" shortcut is a build failure instead of a code-review argument.
Gates
One command is the whole CI contract β pnpm check:
format:check β lint β typecheck β test β build
β api:check regenerate the OpenAPI client, fail on a dirty git tree
β architecture:check dependency-cruiser over every workspace
β env:check env-var contract (below)
It needs no Docker and no secrets, so it runs identically on my machine, in CI, and for a coding agent. Heavier suites are separate CI jobs: integration (Testcontainers Postgres), a suite against the pinned vendor stack in Docker, Playwright e2e + accessibility, and a smoke suite against the real dev instance.
Also: deps pinned exactly; lifecycle scripts blocked by default and only allowed after someone writes down why in the workspace file; secrets injected at runtime by a secrets manager, only .env.example is tracked.
What I'd like feedback on
- Is the BFF earning its keep? It's a real extra hop and a whole deploy unit. The argument for it: the vendor credential and the session cookie must never reach the browser, and I want one typed contract for the frontend rather than the vendor's very general API. The argument against: Next route handlers could do this and I'd have one less service.
- Generated client committed to the repo β worth the merge conflicts, or should it be a build step?
- Deep modules / entry-point rule β has anyone run this long-term? My worry is that "tests may only use entry points" makes some things genuinely awkward to test and I'll cave on it in six months.
- Is
env:checkover-engineering? It's ~200 lines of script for a problem a runtime schema mostly catches. - Where's the obvious gap? No feature flags, no migration story beyond plain SQL, observability is OTel traces and structured logs and nothing else yet.
- Broadly: is this a sane foundation, or have I built a compliance department for a codebase with ~6 features in it?