RedditPP Mascot

RedditPP

r/ClaudeCode β€’ … β€’ u/LazyJuggernaut6177

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