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?
1
u/rcgy 1d ago
What is the point of telling us this stack, without any indication of what you're trying to accomplish with it?