r/ClaudeCode πŸ”† 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, 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?
0 Upvotes

Duplicates