0012 - Adopt Turborepo for Monorepo Task Orchestration and Caching
Status: Accepted Date: 2026-08-11 Author: Manuel Nucci
Context
ADR 0005 moved fpc-shared, fpc-api, fpc-app, and fpc-docs into one pnpm workspace and established dependency-aware CI: Shared changes validate Shared plus its API and App consumers, package-only changes run scoped checks, and root configuration changes run everything. Two gaps have grown out of that plain-pnpm approach since:
- No task-level caching. Root
package.json'sbuildandtestscripts chainpnpm --filter <workspace> <script>calls sequentially. Every invocation reruns every workspace's build and test suite from scratch, locally and in CI, regardless of what changed.packages/sharedrebuilds via tsup on every rootbuild/testrun even when onlyapps/appchanged. - Hand-rolled, job-level change detection.
scripts/ci/detect-changes.mjs(~200 lines, covered by its ownnode --testsuite) inspects the pull request diff and maps changed paths to theshared/api/app/docs/docker/infraoutput flags consumed by.github/workflows/ci.yml's jobif:conditions. It correctly encodes ADR 0005's fan-out rule and is deliberately conservative (unclassifiable diffs run everything), but it is a second, independently maintained implementation of "what does this change affect" that must be updated by hand whenever a workspace, script, or cross-workspace dependency changes. It has no concept of caching a task's output — only of whether a CI job runs at all.
This gap surfaced while researching whether FPC uses Turbopack or a comparable bundler (see conversation context). FPC has no Turbopack usage — Turbopack is Next.js-specific and no app here uses Next.js (apps/app is Vite, apps/api is NestJS/nest build, docs is VitePress, packages/shared is tsup) — but the adjacent, actually-applicable gap is a monorepo task orchestrator with a dependency graph and a build cache, which this repository does not have. Turborepo is the natural fit: it is pnpm-workspace-native, requires no change to the existing per-workspace build tools, and is maintained by the same organization (Vercel) that maintains Turbopack, which is likely the source of the original question.
Decision
Adopt Turborepo (turbo) as the task runner for build, lint, test, and test:e2e across the four workspaces, layered on top of (not replacing) the existing CI job structure:
- Add a root
turbo.jsondeclaring a pipeline withbuild,lint,test, andtest:e2etasks. Giveapi#buildandapp#buildadependsOn: ["^build"]edge sopackages/sharedbuilds first, matching the existing "Shared → API/App/Docs" implementation order in the rootCLAUDE.md. - Replace the hand-chained commands in root
package.json'sbuildandtestscripts withturbo run build/turbo run test, keeping the samepnpm --filter-equivalent workspace names so existing muscle memory (pnpm --filter fpc-api build) still works unchanged. - Enable Turborepo's local filesystem cache by default. Do not enable Vercel Remote Cache in this decision — that requires a Vercel account/token and has an ongoing cost and access-control surface that deserves its own decision once local caching has proven itself.
- Declare explicit
inputs/outputsper task (e.g.,apps/api'sbuildoutput isdist/**; its inputs excludetest/**and*.spec.ts) rather than relying on Turborepo's defaults, so a stale or incomplete cache signature cannot mask a real change. - Do not cache or wrap tasks with external side effects:
db:migrate*,start*,docker:*, and the container/infrastructure GitHub Actions jobs stay exactly as they are today. - Keep
scripts/ci/detect-changes.mjsas the mechanism that gates which GitHub Actions jobs run (it also drives Terraform plan/apply and Docker build eligibility, which are outside Turborepo's scope). Inside the jobs that do run, replace their plainpnpm --filter <workspace> <script>steps withturbo run <task> --filter=<workspace>so retries and same-commit reruns get cache hits; this ADR does not fold the job-gating logic into Turborepo's own--filter=...[<ref>]change detection.
Consequences
Positive
- Local
pnpm build/pnpm testskip workspaces whose declared inputs are unchanged, shortening the inner dev loop, especially forpackages/shared, which currently rebuilds on every root invocation. - CI job retries and same-commit reruns (e.g., a flaky E2E rerun) skip already-passed, unchanged tasks instead of redoing the full build/lint/test sequence.
- The Shared → API/App dependency edge becomes an explicit, declared graph (
turbo.json) instead of an implicit convention documented only inCLAUDE.mdprose and script ordering. - No change to any workspace's underlying build tool (Vite,
nest build, tsup, VitePress) or toscripts/ci/detect-changes.mjs's job-gating role — this is additive.
Negative
- Adds a new root-level dependency and config file (
turbo.json) to keep in sync as workspaces, scripts, or build outputs change. - A
turbo.jsontask with incomplete or wronginputs/outputscan produce a false cache hit that skips a rebuild a change actually required — a new class of bug this repository does not currently have. - Two systems now answer "did this change affect X?" at different granularities:
detect-changes.mjsat the GitHub Actions job level (including non-Turborepo concerns like Terraform and Docker) and Turborepo at the per-task level. Contributors need to know which one governs which behavior. - Without Remote Cache, GitHub Actions runners — which are ephemeral — get no cache benefit between separate workflow runs; only same-run/matrix reuse and local developer machines benefit initially.
Mitigations
- Review each task's
inputs/outputsinturbo.jsonwhenever a workspace's build or test inputs change, as part of the existing "check affected workspace instruction files" step in the rootCLAUDE.mddelivery checklist. - Keep
scripts/ci/detect-changes.mjsand its test suite (node --test scripts/ci/*.test.mjs) as the single source of truth for job-level gating; do not duplicate that logic insideturbo.json. - Document in each workspace's
CLAUDE.md/README which scripts are cacheable (build,lint,test) versus which intentionally bypass Turborepo (db:migrate*,start*, Docker/Terraform jobs). - Revisit Vercel Remote Cache as a follow-up, separately reviewed decision once local caching correctness has been validated in day-to-day use, weighing it against a GitHub Actions cache-action-based alternative.