Skip to content

ADR-0026 Project structure

Company standard repository layout — a Bun workspace with each app under apps/, layer folders inside every app, server-only code fenced in src/server/, environment variables parsed with T3 Env, one naming scheme for files, and squash-merged pull requests titled as Conventional Commits.

Decision
Bun workspace; layer folders; src/server/ fencedBuild the app
Status
Accepted
Date
Scope
Company-wide default for all new apps

Earlier ADRs already fix pieces of an app’s layout, each where it needed one:

  • src/start.ts holds the global request middleware (ADR-0036, ADR-0010); src/router.tsx creates the router and its QueryClient (ADR-0012)
  • src/components/ui/ holds shadcn/ui and src/styles/app.css the theme (ADR-0011); src/components/form/, chart/, virtual/ and markdown/ hold our wrappers (ADR-0013, ADR-0020, ADR-0019, ADR-0021)
  • src/lib/markdown.ts and src/lib/highlight.ts are client-safe helpers (ADR-0021, ADR-0022); src/server/ai/ is server-only (ADR-0025)
  • Tests sit next to their code as *.test.ts, *.test.tsx and *.int.test.ts, with end-to-end specs in e2e/ (ADR-0014); workflows live in .github/workflows/ (ADR-0016)

Nothing draws the whole. Each new repository fills the gaps its own way — where server functions go, where tables live, where API routes go, what a file is called, what a commit message says — so moving between products means learning a new map. Three forces shape the answer:

  • TanStack Start builds one codebase twice, for the server and for the browser (ADR-0002). Its import protection fails the build when a *.server.* file reaches the browser bundle, and it can fence whole folders the same way. A layout that keeps server-only code in one folder turns a leaked database client into a build error
  • Business rules must be testable without HTTP (ADR-0014): server functions and API route handlers only parse, authorize and call plain modules. The layout needs a home for those modules
  • One image runs in every environment (ADR-0016): Nodus injects configuration as environment variables at runtime, and the app validates them with Zod (ADR-0005). Anything Vite inlines at build time is the same in staging and production

Pros: nothing to configure; the scripts, the Dockerfile and the lockfile sit side by side. Cons: a second deployable (a worker, an admin app) or a shared package later means moving every file into apps/ in one large, conflict-prone change.

Pros: every repository has the same shape whether it holds one app or five; a second app or a shared package is a new folder, not a migration. Bun runs scripts across the workspace (bun run --filter), and one lint and one format config at the root cover every package (ADR-0035). Cons: a root package.json and --filter in scripts even when there is only one app; the Docker build context becomes the repository root.

A. Feature folders (src/features/<domain>/)

Section titled “A. Feature folders (src/features/<domain>/)”

Pros: one domain’s components, server functions and queries sit together; deleting a feature deletes a folder. Cons: shared code still needs layer folders, several of them pinned by earlier ADRs, so every repository runs two systems; each new file starts a debate about which feature owns it; server-only files are scattered, and each must carry the .server.ts suffix to be protected.

B. Code beside its route (-components/ folders inside src/routes/)

Section titled “B. Code beside its route (-components/ folders inside src/routes/)”

Pros: a page and the code only it uses are neighbours. Cons: code used by two routes has no home; src/routes/ fills with folders that are not routes.

Pros: one rule for every file — the folder says what the code does; it matches the paths earlier ADRs pinned; all server-only code sits under src/server/, which one import-protection rule fences. Cons: one domain spreads across several folders: components/invoices/, functions/invoices.ts, schemas/invoices.ts, server/services/invoices.ts and so on.

i. The .server.ts suffix only (Start’s default)

Section titled “i. The .server.ts suffix only (Start’s default)”

Pros: no configuration. Cons: every server-only file must remember its suffix; one that forgets is bundled for the browser without a warning.

ii. The suffix plus the src/server/ folder

Section titled “ii. The suffix plus the src/server/ folder”

Pros: everything under src/server/ is fenced by where it lives; the suffix still covers the rare server-only file elsewhere. Cons: one line of plugin configuration, which must restate Start’s default because a custom list replaces it.

a. A Zod schema by hand (schema.parse(process.env), as Start’s guide shows)

Section titled “a. A Zod schema by hand (schema.parse(process.env), as Start’s guide shows)”

Pros: no dependency. Cons: every app re-solves the same details: empty strings in .env files fail number schemas and skip defaults, nothing stops a secret from being read in browser code, and a public variable without the VITE_ prefix is silently undefined in the browser.

Pros: a thin layer over our Zod schemas (Standard Schema) that solves exactly those details: emptyStringAsUndefined, a clientPrefix enforced in types and at runtime, and an error when browser code reads a server variable. TanStack’s own app generator offers it as its environment add-on. Cons: pre-1.0, so pinned to an exact version; one more dependency for what is otherwise a dozen lines.

Pros: nothing to learn or check. Cons: the history on main can’t be scanned or filtered by kind of change; people, coding agents and Renovate each write their own style, and nothing marks the change someone must act on before a deploy.

II. Conventional Commits on every commit, checked by a commit-msg hook

Section titled “II. Conventional Commits on every commit, checked by a commit-msg hook”

Pros: every commit on every branch is typed; a bad message is caught before it is pushed. Cons: commitlint and a hook in every repository; git commit --no-verify skips it; with squash merging, branch commits never reach main, so most of what it checks is thrown away.

III. Conventional Commits on the pull request title, squash-merged

Section titled “III. Conventional Commits on the pull request title, squash-merged”

Pros: one typed commit per pull request on main; one CI step with no dependency checks it, and nobody can skip it; branch commits stay free for work in progress. Cons: main keeps the squashed commit, not a branch’s steps; the merge must use the title, which is a repository setting on GitHub.

A Bun workspace with each app under apps/, layer folders inside every app, and all server-only code under src/server/, fenced by import protection. This is the company standard layout for all new apps. (Amended 2026-11-13: this ADR first chose a pnpm workspace run with Vite+; ADR-0035 moved the toolchain to Bun.)

The example below is a product called billing with one domain, invoices.

A new product repository

Select a file or folder to see what it is for. Coloured icons mark paths an earlier decision already fixed.

  • billing
  • .github
  • workflows
  • ci.yml
  • pr-title.yml
  • apps
  • web
  • drizzle
  • e2e
  • sign-in.spec.ts
  • public
  • src
  • components
  • chart
  • form
  • invoices
  • invoice-table.test.tsx
  • invoice-table.tsx
  • markdown
  • markdown.tsx
  • ui
  • button.tsx
  • virtual
  • functions
  • invoices.ts
  • hooks
  • use-invoice-filters.ts
  • lib
  • highlight.ts
  • markdown.ts
  • utils.ts
  • routes
  • _authed
  • invoices
  • $invoiceId.tsx
  • index.tsx
  • api
  • auth
  • $.ts
  • v1
  • invoices.ts
  • __root.tsx
  • _authed.tsx
  • index.tsx
  • schemas
  • invoices.test.ts
  • invoices.ts
  • server
  • ai
  • adapter.ts
  • chat.ts
  • api
  • v1
  • invoices.int.test.ts
  • invoices.ts
  • http.ts
  • db
  • schema
  • auth.ts
  • index.ts
  • invoices.ts
  • client.ts
  • relations.ts
  • services
  • invoices.int.test.ts
  • invoices.ts
  • auth.ts
  • env.ts
  • telemetry.ts
  • valkey.ts
  • styles
  • app.css
  • router.tsx
  • routeTree.gen.ts
  • start.ts
  • .env
  • .env.example
  • bunfig.toml
  • components.json
  • Dockerfile
  • drizzle.config.ts
  • instrumentation.ts
  • package.json
  • playwright.config.ts
  • tsconfig.json
  • vite.config.ts
  • docs
  • decisions
  • packages
  • .gitignore
  • .oxfmtrc.json
  • .oxlintrc.json
  • AGENTS.md
  • bun.lock
  • compose.yaml
  • package.json

billing

./

One repository per product, a Bun workspace from the first commit. Run everything from here with bun; bun run --filter @billing/web <script> targets one app.

Set by

This decision

Show as table
PathWhat it is forSet by
./One repository per product, a Bun workspace from the first commit. Run everything from here with bun; bun run --filter @billing/web <script> targets one app.This decision
.github/Forge configuration.This decision
.github/workflows/The one portable workflow set, run by GitHub Actions and Forgejo Actions alike. Never add .forgejo/workflows/.0016CI/CD
.github/workflows/ci.ymlChecks and tests every commit, then builds one immutable image per app for Nodus to deploy.0016CI/CD
.github/workflows/pr-title.ymlFails a pull request whose title is not a Conventional Commit. The title becomes the squash commit on main.This decision
apps/One folder per deployable. Each app builds its own Docker image; a second app, such as a worker, is a sibling of web/.This decision
apps/web/The TanStack Start app: the UI, server functions and the API routes, in one container. Its package.json is named @billing/web.0002App framework
apps/web/drizzle/Migrations written by drizzle-kit generate. Committed and reviewed, never edited by hand; CI and deploy apply them.0008ORM / query layer
apps/web/e2e/Playwright specs for each critical journey, run against the production build.0014Testing
apps/web/e2e/sign-in.spec.tsE2E specs end in .spec.ts and live outside src/, the only folder bun test searches.0014Testing
apps/web/public/Files served as they are from the site root, such as favicon.ico and robots.txt. Nothing that needs a build step.This decision
apps/web/src/Application code, in layer folders. The folder says what a file does; the file name says which domain it belongs to.This decision
apps/web/src/components/React components. Shared wrappers sit in their own folders; product components sit in one folder per domain.This decision
apps/web/src/components/chart/Our thin wrappers around TanStack Charts, each with its table twin.0020Charts
apps/web/src/components/form/Form fields bound to TanStack Form and shadcn/ui Field.0013Forms
apps/web/src/components/invoices/The components of one domain, in a folder named after it.This decision
apps/web/src/components/invoices/invoice-table.test.tsxComponent test, next to the component, run by bun test in happy-dom. Focus, keyboard and pointer behavior is tested in e2e/.0014Testing
apps/web/src/components/invoices/invoice-table.tsxOne component per file, named after it: invoice-table.tsx exports InvoiceTable. Named exports only.This decision
apps/web/src/components/markdown/The one Markdown component every screen uses.0021Markdown
apps/web/src/components/markdown/markdown.tsxRenders model and user text with raw HTML off.0021Markdown
apps/web/src/components/ui/shadcn/ui components on Base UI, written by the shadcn CLI and ours to edit.0011Styling
apps/web/src/components/ui/button.tsxAs shadcn add button wrote it, then edited freely.0011Styling
apps/web/src/components/virtual/Wrappers around TanStack Virtual for long lists and large grids.0019Virtual lists
apps/web/src/functions/Server functions (createServerFn), one file per domain. Each parses its input with a shared schema, checks access and calls a service; no business rules here.This decision
apps/web/src/functions/invoices.tsgetInvoices, createInvoice and the rest of the domain. Safe to import from components: the browser gets RPC stubs.This decision
apps/web/src/hooks/React hooks shared by several components. use-<name>.ts exports use<Name>.This decision
apps/web/src/hooks/use-invoice-filters.tsReads and writes the list's filters in the URL.This decision
apps/web/src/lib/Client-safe helpers with no React and no server imports.This decision
apps/web/src/lib/highlight.tsThe one highlighter and its explicit language list.0022Syntax highlighting
apps/web/src/lib/markdown.tsThe link and image policy every Markdown render shares.0021Markdown
apps/web/src/lib/utils.tscn() from shadcn/ui. A helper used by one file stays in that file.This decision
apps/web/src/routes/File routes. A route file holds its loader, its guards and its page, composed from components/; nothing else lives here.This decision
apps/web/src/routes/_authed/Pages behind sign-in. The _ prefix adds no URL segment.This decision
apps/web/src/routes/_authed/invoices//invoices and everything under it.This decision
apps/web/src/routes/_authed/invoices/$invoiceId.tsx/invoices/:invoiceId. Params are camelCase and say what they hold: $invoiceId, not $id.This decision
apps/web/src/routes/_authed/invoices/index.tsx/invoices, the list. Paging, sorting and filters live in the URL.This decision
apps/web/src/routes/api/Server routes for everything consumed as HTTP. Route files only wire; the handlers live in server/api/.0036API layer
apps/web/src/routes/api/auth/Better Auth's endpoints.This decision
apps/web/src/routes/api/auth/$.tsSplat server route: hands every /api/auth/* request to auth.handler(request).0009Auth
apps/web/src/routes/api/v1/The versioned API. Callers authenticate with a token, never the session cookie.0036API layer
apps/web/src/routes/api/v1/invoices.ts/api/v1/invoices: maps GET and POST to the handlers in server/api/v1/invoices.ts.This decision
apps/web/src/routes/__root.tsxThe document shell: <html>, head tags, providers and the error boundary.This decision
apps/web/src/routes/_authed.tsxPathless layout: checks the session once for every route in _authed/.This decision
apps/web/src/routes/index.tsxThe home page, /.This decision
apps/web/src/schemas/Zod schemas shared by forms, server functions and API routes. Client-safe.0005Input validation
apps/web/src/schemas/invoices.test.tsUnit test, next to the file it tests.0014Testing
apps/web/src/schemas/invoices.tsThe domain's input and filter schemas, derived from the tables with drizzle-orm/zod where the shapes overlap.This decision
apps/web/src/server/Server-only code. Import protection fails the build if anything here reaches the browser bundle.This decision
apps/web/src/server/ai/The AI SDK's server side.0025AI SDK
apps/web/src/server/ai/adapter.tsThe model provider this product chose in its own ADR.0025AI SDK
apps/web/src/server/ai/chat.tsThe chat handler and its tools.0025AI SDK
apps/web/src/server/api/The API handlers that the route files in routes/api/ call.0036API layer
apps/web/src/server/api/v1/One file of handlers per resource; the version is in the path.This decision
apps/web/src/server/api/v1/invoices.int.test.tsIntegration test that calls the handlers with a Request and a caller, against real Postgres and Valkey.0014Testing
apps/web/src/server/api/v1/invoices.tsThe /api/v1/invoices handlers: validated with the shared schemas, calling the same service as the server functions.This decision
apps/web/src/server/api/http.tsWhat every handler shares: readJson, readQuery and the ApiHandler type.This decision
apps/web/src/server/db/Everything that talks to Postgres.0008ORM / query layer
apps/web/src/server/db/schema/Drizzle tables, one file per domain. drizzle.config.ts reads this folder.This decision
apps/web/src/server/db/schema/auth.tsBetter Auth's tables, written by auth generate. Regenerate, never edit.0009Auth
apps/web/src/server/db/schema/index.tsRe-exports every table for the client and for Drizzle Kit.This decision
apps/web/src/server/db/schema/invoices.tsThe invoices table. Table names are plural, like the URL.This decision
apps/web/src/server/db/client.tsThe Bun.SQL pool, wrapped by traceSql, and the Drizzle client; the only file that connects.0008ORM / query layer
apps/web/src/server/db/relations.tsEvery relation in one defineRelations() call, so joins across domains have one home.This decision
apps/web/src/server/services/Business rules, one file per domain: plain functions that take their dependencies (database, clock, clients) as parameters.This decision
apps/web/src/server/services/invoices.int.test.tsIntegration test that calls the service directly.0014Testing
apps/web/src/server/services/invoices.tsThe rules for invoices, shared by the server functions and the API handlers.This decision
apps/web/src/server/auth.tsThe Better Auth instance: Drizzle adapter, Valkey secondary storage and plugins.0009Auth
apps/web/src/server/env.tsEvery server variable, declared once with T3 Env's createEnv and read from process.env at runtime. No other file reads process.env.This decision
apps/web/src/server/telemetry.tsSpan helpers: trace and traceServerFn for src/start.ts, traceSql for the Postgres client, and withSpan for storage and mail calls. Each goes when Bun or Start traces that hop itself.0015Observability
apps/web/src/server/valkey.tsThe shared ioredis client for rate limits and sessions.0010Rate limiting
apps/web/src/styles/Global styles.This decision
apps/web/src/styles/app.cssTailwind's entry point and the theme tokens; the only global stylesheet.0011Styling
apps/web/src/router.tsxCreates the router and its QueryClient.0012Data fetching & state
apps/web/src/routeTree.gen.tsWritten by the router plugin on every dev and build run. Committed, never edited.This decision
apps/web/src/start.tsGlobal request middleware, run before every page, server function and /api/* call: the request span and ID, rate limits, the CSRF check, sessions, and the API caller for /api/v1/*; plus a span for every server function.0010Rate limiting
apps/web/.envLocal secrets and settings, loaded by Bun automatically. Git-ignored; start from .env.example.This decision
apps/web/.env.exampleEvery variable env.ts reads, with safe placeholder values. Committed.This decision
apps/web/bunfig.tomlbun test settings: root = "src", so e2e/ is never collected, and lcov coverage.0014Testing
apps/web/components.jsonThe shadcn/ui CLI's settings: the Base UI style and the @/ aliases.0011Styling
apps/web/DockerfileBuilds this app's image: built with Bun, run on Bun's distroless image as a non-root user. Its build context is the repository root, so the lockfile is in reach.0016CI/CD
apps/web/drizzle.config.tsPoints Drizzle Kit at src/server/db/schema/ and drizzle/.0008ORM / query layer
apps/web/instrumentation.tsStarts OpenTelemetry and wraps fetch. The image runs it with bun --preload, before the server, the worker and the migration runner.0015Observability
apps/web/package.json@billing/web, with the dev, build, test, test:int and test:e2e scripts.0035Runtime & toolchain
apps/web/playwright.config.tsRuns e2e/ against the production build, with Postgres and Valkey in containers.0014Testing
apps/web/tsconfig.jsonStrict, with the @/* alias for src/*.This decision
apps/web/vite.config.tsThe Start plugin with its import protection, and Nitro's bun preset.0035Runtime & toolchain
docs/Product documentation.This decision
docs/decisions/This product's own ADRs, in this site's format: deviations from these defaults, and choices left to each product, such as the AI provider.This decision
packages/Code imported by two or more apps, one folder per package, named @billing/<name>. Starts empty: code moves here when a second app needs it.This decision
.gitignoreIgnores node_modules/, build output and .env files.This decision
.oxfmtrc.jsonFormat settings for every package.0035Runtime & toolchain
.oxlintrc.jsonLint rules for every package. A package that needs different rules gets an overrides entry here, never its own config.0035Runtime & toolchain
AGENTS.mdWhere things go, which commands to run and the stack, for people and coding agents alike. Copied from the site's AGENTS.md template.This decision
bun.lockThe only lockfile. Text, so it is reviewed in diffs; bun ci fails when it is out of date.0035Runtime & toolchain
compose.yamlLocal Postgres and Valkey, on the same image tags as production.0003Deploy target
package.jsonThe workspace root: private, no app code. packageManager pins Bun; workspaces lists apps/* and packages/* and holds the version catalog.0035Runtime & toolchain

Concretely:

  • One repository per product, a Bun workspace from the first commit. apps/<name>/ holds each deployable — the TanStack Start app is apps/web/ — with its own Dockerfile, image and package.json, named @<product>/<name>. packages/<name>/ holds code that two or more apps import and starts empty: code moves there when a second app needs it, not before

  • The root holds the workspace, not app code: a package.json with packageManager (the Bun version), workspaces.packages and the version catalog in workspaces.catalog, and bun.lock (ADR-0035); compose.yaml for local Postgres and Valkey (ADR-0003), .github/workflows/, docs/decisions/ and AGENTS.md. .oxlintrc.json and .oxfmtrc.json at the root hold the lint and format rules for every package; a package that needs different lint rules gets an overrides entry there, never its own config. Each app’s own vite.config.ts holds its build, and its bunfig.toml its test settings (ADR-0014)

  • Inside an app, folders are layers. The folder says what a file does; the file name says which domain it belongs to. Imports flow down this table, and only server function handlers and the API route files under routes/api/ reach into server/:

    Folder Holds Imports from
    routes/ Route files: loader, guards and the page, composed from components/ components/, hooks/, functions/, schemas/, lib/
    components/ Shared wrappers in their own folders; product components in components/<domain>/ components/, hooks/, functions/, schemas/, lib/
    hooks/ React hooks shared by several components functions/, schemas/, lib/
    functions/ Server functions, one file per domain schemas/, lib/; server/ inside handlers only
    schemas/ Zod schemas shared by forms, server functions and API routes (ADR-0005) lib/
    lib/ Client-safe helpers, with no React and no server imports lib/
    server/ Server-only code: db/, services/, api/, ai/, jobs/, mail/, storage/, auth.ts, env.ts, telemetry.ts, valkey.ts; the only folder that imports from bun server/, schemas/, lib/
  • src/server/ is fenced. The browser build denies the whole folder as well as the *.server.* suffix, so a server-only import that reaches the browser bundle fails the build, and warns in dev:

    apps/web/vite.config.ts
    tanstackStart({
    importProtection: {
    // A custom list replaces Start's default, so the suffix rule is restated.
    client: { files: ["**/*.server.*", "**/server/**"] },
    },
    }),

    The .server.ts suffix is only for a server-only file that must live elsewhere, and .client.ts for browser-only code the server must never load

  • Handlers stay thin (ADR-0014). Business rules live in server/services/<domain>.ts, as plain functions that take their dependencies as parameters. A server function in functions/<domain>.ts and an API handler in server/api/v1/<domain>.ts parse their input with the shared schema, check access and call the service

  • API routes are server routes that only wire (ADR-0036). routes/api/v1/<domain>.ts maps methods to the handlers in server/api/v1/<domain>.ts; server/api/http.ts holds what every handler shares — readJson, readQuery, and the ApiHandler type. routes/api/auth/$.ts hands /api/auth/* to Better Auth (ADR-0009), and webhooks live in routes/api/webhooks/. Start’s global request middleware — rate limiting first (ADR-0010) — runs before all of them:

    apps/web/src/routes/api/v1/invoices.ts
    import { createFileRoute } from "@tanstack/react-router";
    import { createInvoice, listInvoices } from "@/server/api/v1/invoices";
    export const Route = createFileRoute("/api/v1/invoices")({
    server: { handlers: { GET: listInvoices, POST: createInvoice } },
    });
  • The database lives in server/db/ (ADR-0008). client.ts is the only file that connects. schema/ holds one file of tables per domain, plus auth.ts, which Better Auth’s CLI writes and nobody edits; schema/index.ts re-exports them all, and relations.ts holds the single defineRelations() call. drizzle.config.ts reads schema/ and writes migrations to drizzle/ at the app root

  • Environment variables are parsed once, with T3 Env. server/env.ts declares every server variable with createEnv from @t3-oss/env-core, pinned to an exact version while pre-1.0, and exports env; no other file reads process.env. It reads process.env, not import.meta.env, because Vite inlines import.meta.env at build time and one image runs everywhere (ADR-0016):

    apps/web/src/server/env.ts
    import { createEnv } from "@t3-oss/env-core";
    import * as z from "zod";
    export const env = createEnv({
    server: {
    APP_ORIGIN: z.url(),
    CLIENT_IP_HEADER: z.string().min(1),
    DATABASE_URL: z.url(),
    VALKEY_URL: z.url(),
    BETTER_AUTH_SECRET: z.string().min(32),
    },
    runtimeEnv: process.env,
    emptyStringAsUndefined: true,
    });

    A bad configuration fails the first request that loads env. The /readyz probe is that request, because it reaches Postgres and Valkey (ADR-0015), so the instance never takes traffic (ADR-0017). Public values the browser needs go in lib/env.ts, created only when there is one, with clientPrefix: "VITE_" and runtimeEnv: import.meta.env; they are baked into the image, so nothing that differs between environments belongs there — the server sends those through a loader. .env.example lists every variable and is committed; .env is git-ignored

  • Names:

    • Files and folders are kebab-case: invoice-table.tsx, use-invoice-filters.ts. The shadcn/ui CLI writes the same
    • One component per file, named after it, as a named export: invoice-table.tsx exports InvoiceTable. No default exports, except where a tool requires one, such as a config file
    • Hooks are use-<name>.ts and export use<Name>
    • Domain names are plural and match the URL: /invoices, functions/invoices.ts, the invoices table
    • Suffixes are kept for what tools read: .test.ts and .test.tsx run with bun run test, .int.test.ts with bun run test:int (ADR-0014); .spec.ts marks Playwright specs in e2e/, outside the src/ folder bun test searches; .server.ts and .client.ts trigger import protection; .gen.ts marks a generated file. There are no role suffixes such as .service.ts: the folder already says the role
  • Routes use the router’s file syntax, as folders: invoices/$invoiceId.tsx, not invoices.$invoiceId.tsx. A pathless layout starts with _ (_authed.tsx guards every route in _authed/), a (group)/ folder organizes routes without adding a URL segment, and params are camelCase and say what they hold: $invoiceId, not $id. Nothing but route files lives in routes/

  • Imports use the @/ alias for anything outside the current folder (@/components/ui/button), as shadcn/ui’s components.json sets it up; relative imports stay within one folder

  • Pull requests are squash-merged, and the title is a Conventional Commits 1.0.0 message, because it becomes the one commit on main: <type>(<scope>)!: <summary>, such as feat(invoices): export to CSV

    • Types: feat and fix for what users notice; perf, refactor, test, docs, style, build, ci, chore and revert for the rest. It is commitlint’s conventional set, which people and agents already know
    • The scope is optional: a domain (invoices) or an app or package (web). Renovate uses deps
    • ! marks a change someone outside the pull request must act on: a public API route removed or changed (ADR-0036), a new required environment variable Nodus must set before the deploy, or the contract step of a migration (ADR-0016)
    • Commits on a branch are free-form; the squash replaces them
    • Squash is the only merge style the repository allows. On GitHub, the squash commit message is set to default to the pull request title; Forgejo uses the title already. Renovate is set to semanticCommits: "enabled" rather than left to detect the convention, so its pull requests are titled chore(deps): … or fix(deps): … from the first one
    • One workflow step checks the title. It re-runs when the title is edited, is a required check on main with the others (ADR-0016), and reads the title from an environment variable, because a title is untrusted input and must never be expanded inside the script:
    .github/workflows/pr-title.yml
    name: pr-title
    on:
    pull_request:
    types: [opened, edited, synchronize, reopened]
    permissions:
    contents: read
    jobs:
    title:
    runs-on: ubuntu-latest
    steps:
    - name: Title is a Conventional Commit
    env:
    TITLE: ${{ github.event.pull_request.title }}
    run: printf '%s\n' "$TITLE" | grep -Eq '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .+'
  • Product decisions live in the product: docs/decisions/NNNN-short-slug.md, in this site’s ADR format. A deviation from these defaults, or a choice an ADR leaves to each product, is written there

  • AGENTS.md at the root, copied from the AGENTS.md template, names the commands, the stack and these rules, so people and coding agents start from the same map

The deciding factors: one map for every product, the paths earlier ADRs already chose, and a server boundary the build enforces rather than one reviewers must spot. We accept one domain spread across several folders, and workspace overhead in repositories that only ever hold one app.

Easier:

  • Moving between products: server functions, tables, API routes and tests are in the same place in every repository
  • A server-only import that reaches the browser fails the build, instead of shipping a database client or a secret
  • A second deployable or a shared package is a new folder, not a restructuring
  • A missing or malformed environment variable stops an instance at its readiness check, with every problem listed, instead of failing deep inside a request
  • Coding agents follow the same rules as people, written once in AGENTS.md
  • main reads as a change log: one typed line per pull request, with ! on whatever must be acted on before a deploy

Harder:

  • One domain touches several folders, so adding or removing a feature means visiting each. Searching for the domain name finds them all, because every file carries it
  • Workspace overhead in single-app repositories: workspaces in the root package.json, --filter in root scripts, and a Dockerfile whose build context is the repository root
  • The import-protection list restates Start’s default; a Start upgrade that changes the default must be checked against it
  • T3 Env is pre-1.0: pinned exactly and upgraded deliberately, like the libraries in ADR-0023 to ADR-0025
  • A pull request gets one type, so unrelated changes go in separate pull requests; a branch’s individual commits are not kept on main

Given up: the locality of feature folders and the simplicity of a single package. Accepted — the same map in every repository, and a server boundary the build enforces, are worth a few more folders to open.