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, Tailwind * `bff` β Fastify, TypeBox schemas β OpenAPI, pino, OpenTelemetry * `worker` β 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
1. **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. 2. **Generated client committed to the repo** β worth the merge conflicts, or should it be a build step? 3. **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. 4. **Is** `env:check` **over-engineering?** It's \~200 lines of script for a problem a runtime schema mostly catches. 5. **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. 6. Broadly: is this a sane foundation, or have I built a compliance department for a codebase with \~6 features in it?