CLAUDE.md
June 13, 2026 · View on GitHub
Generic monorepo boilerplate. Clean Architecture + DDD. No business features.
Detailed rules live in sub-CLAUDE.md files — auto-loaded by Claude Code via recursive lookup the moment you read/edit a file under the path.
apps/api/CLAUDE.md→ high-level server (CQRS, DI inwire, Hono RPC, BetterAuth server, storage, org scoping, logging). Read before touchingpackages/{ddd-kit,drizzle,access-control}/**too.
apps/api/src/modules/CLAUDE.md→ per-module: layers, DDD primitives, domain events, testing, code patternsapps/api/src/shared/CLAUDE.md→ shared kernel: port placement decisor, transaction.ts exceptionapps/app/CLAUDE.md→ high-level client (layout, import direction, naming, theme). Read before touchingpackages/{ui,access-control}/**too.
apps/app/src/features/CLAUDE.md→ per-feature: anatomy, routing 2-file pattern, queries/mutations, composition patterns, form/schema contractsapps/app/src/shared/CLAUDE.md→ shared front: api-client, auth-client, route gates, authorization, org-scoping frontFor conceptual / cross-cutting questions ("how should I structure a new module?", "where does this rule live?"), read the relevant sub-CLAUDE.md before answering — that's where the rules are.
Philosophy
Lean Startup — Build → Measure → Learn. Stack ships SaaS plumbing (auth, billing, multi-tenant, email, storage) and isolates the domain so pivots don't trash the foundation. "Done > perfect" applies to features; the rules in the sub-CLAUDE.md are non-negotiable — what makes shipping fast sustainable.
Working method
Library API/config/SOTA unclear → check docs first. Outdated patterns are a frequent failure mode. Primary: Context7 MCP via explore-docs. Fallback: websearch/WebFetch.
Stack
- Runtime: Bun 1.3+ (api+scripts), Node 24.15+ (tooling) · API: Hono on native
Bun.serve()—bun build(prod),bun --hot(dev) - App: Vite 8 + React 19 + TanStack Router/Query + Tailwind 4 · UI: shadcn/ui (
@packages/ui) +sonner+next-themes - DB: Drizzle + Postgres 17 (Docker, port
5433) · DI:inwire(type-inference container) - Auth: BetterAuth (Drizzle adapter +
twoFactor,passkey,magicLink,bearer) — module-level singleton, never wrapped in DI · Security (rate-limiter-flexiblefail-closed on auth routes, Origin-based CSRF, CSP nonce via Caddy+Vite — detail inapps/api/CLAUDE.md) - Observability:
pino+hono-pino· Contract: Hono RPC (hc<AppType>) - Primitives:
@packages/ddd-kit(Result,Option,Entity,Aggregate,ValueObject,UUID,DomainEvent,BaseRepository,ScopedRepository,IUnitOfWork) - Tooling: pnpm 10 + Turborepo + Biome + Husky + commitlint + semantic-release + knip + jscpd · Testing:
bun test(api) +vitest(packages, app)
Cross-cutting rules (always apply)
- Adding a rule — omnipotent or it doesn't belong. A rule states a principle tied to an architectural property and survives swapping any library/version/path it references. Phrase library-agnostic; only name a tool when it is the property (Zod = "validate at boundary"). Always include the why. Promote on 2nd occurrence. Rewrite or delete a rule the moment its property changes.
- Reusability-first — promote, don't duplicate. 2nd occurrence is the trigger. Once promoted, call site has zero cosmetics.
- Zero warnings, zero errors before push. Husky/lint-staged/commitlint/pre-push/CI green (Biome, knip, jscpd, type-check). No
--no-verify. Intentional warning →/* biome-ignore <rule>: <why> */. Contract: greenpnpm ci:check. - Internal packages ship source, not build artifacts. Private workspace packages (consumed only in-monorepo) point
exportsdirectly atsrc/; nomain/typespointing to emitted output, nobuildscript, no generateddist/. Why: file-watchers in dev (container sync, IDE, native watchers) almost always exclude generated directories to avoid host↔runtime collision — a build step at startup then makes every source change produce a stale artifact until manual rebuild. Source types are also more accurate than emitted.d.ts, and modern bundlers inline workspace deps from source at app build time. Exception: a package explicitly designed for npm publishing keeps its build pipeline, but its top-levelmain/types/exportsstill point atsrc/— the rewrite todist/happens only at publish time viapublishConfig.{main,types,exports}(pnpm/npm swap these in duringpnpm publish). Why: pointing top-level fields atdist/re-creates the stale-artifact trap for every monorepo consumer (fresh clone has nodist/→Cannot find moduleuntil someone runspnpm build). The moment the answer to "is this published?" is no, the build (and thepublishConfigblock) goes. - ORM-first ; raw SQL only when the ORM has no equivalent. All data access through the typed query builder (
select/insert/update/delete+ helpers likearrayContains,isNull,inArray,.for("update", { skipLocked: true })). Reach forsql\...`template tags only for what the ORM doesn't model: DDL (CREATE TRIGGER,CREATE FUNCTION), session-scoped runtime tuning (SET LOCAL idle_in_transaction_session_timeout), or PostgreSQL operators with no helper (rare — check the ORM exports first). **Why**: the typed builder catches column rename/type drift at compile time; raw SQL silently rots until a runtime exception in prod. **Test before reaching forsql``**: search the ORM's exports for the operator name (arrayContains,lateral,with, …) — if it's there, use it. If not, the raw fragment is acceptable but should keep its column references typed (${table.col}`) so a rename still breaks the build. - Every state change emits a domain event ; if it's worth doing, it's worth observing. Any action that mutates persistent state (aggregate transition, lifecycle hook, service-level write, lib-callback like BetterAuth/Stripe webhook) declares a typed event in
@packages/events(1 line inevent-types.ts+ Zod payload + retention) and emits it — viaaggregate.addEvent()insideuow.run()for domain code, oremitEvent(outbox, ...)for service/lib-bridge code that lives outside an aggregate. Why: the rail (audit_log + webhooks + future analytics/notification handlers) is opt-out, not opt-in — every silent mutation is a compliance gap (no audit trail), an integration gap (consumers can't subscribe), and a debugging gap (no event timeline). The cost of adding the event is ~5 lines; the cost of retrofitting one across N call sites later is hours. Test before merging a state change: trace the action to aaddEvent(...)oremitEvent(...)call within the same TX. If you can't find one, you forgot. Two recurring traps: (a) declaring an event in the catalog without an emit site (orphan — caught by anoutbox_event WHERE event_type = '<X>'query returning zero in the QA pass); (b) wiring only one of multiple lib lifecycles that produce the same state (e.g. BetterAuth'safterAddMemberANDafterAcceptInvitationboth produce a member; missing one drops every flow that uses the missed path). Exception — infra retention sweeps:DELETE … WHERE created_at < cutoffon derived pipeline tables (outbox_event,audit_log,webhook_delivery) is not a state change — the business event was already emitted at write time, and the sweep deletes its own audit row. No domain event required for the purge itself. - Every event payload identifies its actor ; an audit row with
actorType="system"must be the real exception, not a default. Every payload of a state-change event carries the user who triggered it under a recognized key, in this priority order (matchesAuditEventSubscriber.extractActor):actorUserId(canonical, REQUIRED when the actor differs from the subject) →inviterUserId→ownerUserId→userId(only when subject == actor: self-actor flows like sign-in, self-deletion, MFA toggle). Why: RGPD compliance hinges on "who did what to whom"; if the audit_log row hasactor_id=null, actor_type=system, the trace is broken and the audit has zero forensic value. Same-payloaduserIdis the subject (the row whose state changed) — when the actor is someone else (admin kicks member, owner changes role, system cron sweeps),actorUserIdis mandatory and distinct fromuserId. Test before merging an event payload: for every event, ask "is the subject the actor?". If no →actorUserIdmust be a separate, NOT NULL field. If genuinely system-triggered (cron, cascade, webhook bridge with no upstream user),actorUserIdcan be nullable but the nullability must be explicit in the Zod schema, not implicit. Two recurring traps: (a) reusinguserIdas actor when the row is a target (e.g.ORG_MEMBER_REMOVED { userId }—userIdhere is the kicked member, not the admin who kicked); (b) forgetting to propagate the actor from the HTTP boundary down to the service — when the service is called from a route,c.get("user").idmust be threaded through as an explicitactorUserIdargument, never inferred later from session ALS. - Every I/O method declares a span ; every catch surfaces the error to telemetry. Repos and external-I/O services receive
IInstrumentationvia constructor and wrap their public methods : outer span{ name: "ClassName > methodName" }over the body, inner span{ name: query.toSQL().sql, op: "db.query", attributes: { "db.system.name": "postgresql" } }(orop: "http.client"for HTTP) around the actualquery.execute()/client.send()/fetch()call,catch + this.instrumentation.capture(err) + return Result.fail(...)(or rethrow if the method bare-throws). Why: errors silently swallowed inside an infra catch are the #1 source of "we have no idea why it broke in prod" — Sentry hooks here, OTel/Tempo hook here when D.1 lands, all without changing call sites. The cost is ~6 lines per method ; the cost of retrofitting across N repos is hours. Test before merging a new repo: every public method either callsthis.instrumentation.startSpan(...)or has a documented reason not to (pure compute, no I/O). Three recurring traps: (a) using a module-level singleton instead of constructor injection — breaks testability and the "swap the binding to NoOp = remove the provider" contract ; (b) wrapping multi-query methods (e.g.executeWipe) with N inner spans — noise, keep only the outer ; (c) calling sibling-repo methods (this.findById) from inside a span — the inner spans become orphaned siblings rather than children, inline the query. Exception — pure-compute helpers (HMAC sign, jitter math, mappers) get neither span nor capture ; the instrumentation cost would dwarf the work. Front counterpart: unexpected async failures (5xx, network errors) reach telemetry through global error handlers wired once at the query-client level (apps/app/src/shared/observability/), never through per-call-sitecaptureErrorin localonErrorcallbacks ; expected failures (4xx, cancellations, explicit flow-control signals) stay UX-only. Why: global handlers make every new query/mutation observable without opting in, and per-callback capture both double-reports and bypasses the expected-error filter — the single capture point survives swapping the data-fetching library, local callbacks don't.
Turborepo
ui: "tui"; daemon auto-managed since v2.x. globalDependencies (biome.json, pnpm-workspace.yaml, .env*) bust every cache. inputs scoped per task — README/doc edits don't invalidate code caches. build declares with: ["type-check"] (parallel for free). dev, test:watch, db:studio are interruptible: true.
Scripts & DB
pnpm bootstrap (copies .env.example→.env in each workspace, idempotent — scripts/bootstrap.sh). pnpm dev (Turbo TUI, --filter=api to scope) · dev:docker (compose up --watch — fully containerized hot reload) · build · test · type-check · check (Biome) · fix · ci:check · check:duplication (jscpd) · check:unused (knip) · db:push/generate/migrate/seed/studio · clean. Postgres on localhost:5433 via docker compose up postgres -d for native dev — full stack via docker compose up --watch for containerized dev (api+app+postgres). docker compose up -d without args spins up postgres+api+app and will collide with pnpm dev on ports 3000/5173. Don't pin Postgres back to 5432 — collides with system Postgres. Storage opt-in via docker compose --profile storage up seaweedfs seaweedfs-init -d (SeaweedFS, host port pinned to 8333). After schema change: pnpm db:push (dev — drizzle-kit push --force for non-TTY safety under Turbo) or db:generate && db:migrate (prod-style). API runs on Bun natively — don't reintroduce @hono/node-server/tsx/tsc-alias.
Release flow
Two-branch model. main = released; dev = integration. Every merge to main triggers semantic-release.
- Conventional Commits required (commitlint enforces lower-case subject). Release impact (
.releaserc.json):feat→minor;fix/perf/refactor/revert/buildanddocs(readme)→patch;docs/style/test/chore/ci→no release;BREAKING CHANGE:(or!)→major. Pick type for the release impact you want, not the file touched. - Daily work on
dev(or feature branches PR'd intodev). Pushing todevdoes not release. - Shipping = open PR
dev→main, merge it. semantic-release analyzes commits since last tag → one bundled bump+changelog. dev→mainMUST be a merge commit (not squash, not rebase) — squash collapses every conventional commit into one, semantic-release would see one entry. GitHub-side allows merge commits only.mainis protected (require PR, no force push, conversation resolution). CI fix during release → ondev, re-merge.- Don't release on every commit — wait for a meaningful batch. Don't push directly to
main.