# ADR-010: Turborepo boundaries alongside ESLint enforcement **Status:** Accepted **Date:** 2026-05-04 **Supersedes:** ADR-006 (partial refinement; not a contradiction) ## Context ADR-006 established the vertical-feature monorepo with three tags (`app`, `feature`, `core`). ESLint with `eslint-plugin-boundaries` was introduced to enforce direct-import boundaries at lint time, catching violations like: - Feature package importing from another feature - Core package importing from a feature - Deep imports past public `exports` map boundaries However, ESLint has two intrinsic limitations: 1. **No transitive enforcement** — If `core-trpc` imports `core-api` (which imports feature routers), ESLint sees only the direct edge `core-trpc` → `core-api`. The transitive reach into features is invisible. A package can declare `@repo/feature-x` as a dependency without ever importing it directly. 2. **No declared-dep enforcement** — A `package.json` dependency that doesn't match an actual import goes undetected. Conversely, a `package.json` dependency might be missing but the transitive graph still allows the code to work. The result: two types of dependency drift escape lint-time checking: - A composition package's transitive reach into features (real problem: `core-trpc` → `core-api` → `feature-blog-routers`) - Incorrect or missing `package.json` declarations ## Decision Add Turborepo's `boundaries` feature as a second enforcement layer running at **build-graph time** (before build), parallel to ESLint's lint-time checks. ### Five-tag model (refined from ADR-006) ADR-006 mentioned three tags. This ADR refines the model to five, distinguishing composition packages explicitly: - **app** — `apps/web-next`, `apps/web-tanstack`, `apps/cms`, `apps/storybook` - **core** — `packages/core-shared`, `core-ui` (pure foundation, no transitive feature reach) - **core-composition** — `packages/core-api`, `core-cms`, `core-trpc` (composition or transitively reach features) - **feature** — `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation` - **tooling** — `packages/core-eslint`, `core-typescript` Why `core-trpc` is `core-composition`: - `core-trpc` imports `@repo/core-api` (the tRPC app router) - `core-api` imports feature routers from `@repo//api` - Therefore, `core-trpc` transitively depends on features through the `AppRouter` type - This violates ADR-006's "core → NOT feature" rule if we treat `core-trpc` as plain `core` - Solution: tag `core-trpc` as `core-composition` to make the transitive reach explicit and allowed ### Implementation 1. **Root `turbo.json`** declares boundary rules as a `boundaries.tags` config: ```json { "boundaries": { "tags": { "app": { "dependencies": { "allow": ["app", "core", "core-composition", "feature", "tooling"] } }, "core-composition": { "dependencies": { "allow": ["core", "core-composition", "feature", "tooling"] } }, "core": { "dependencies": { "allow": ["core", "core-composition", "tooling"] } }, "feature": { "dependencies": { "allow": ["core", "feature", "tooling"] } }, "tooling": { "dependencies": { "allow": ["tooling"] } } } } } ``` > **Amendment (2026-05-21) — feature → feature.** The `feature` tag's allow-list > includes `feature`. A feature package may depend on another feature's > _published public exports_ — the `@repo/` contract barrel (types, > schemas, errors, event contracts). This is required by the cross-feature > event system (ADR-015): a consumer must import the publisher's event > contract. Two guardrails remain: each feature's `exports` map seals its > internals (only `.`, `./ui`, `./api`, `./cms`, `./di/bind-*` are reachable), > and cross-feature _behaviour_ still flows through `IEventBus` — a feature > never imports and invokes another feature's use cases directly. 2. **Per-package `turbo.json`** declares the package's tag: ```json // packages/blog/turbo.json { "extends": ["../../../turbo.json"], "tasks": { /* ... */ } } // package.json { "turbo": { "tasks": { "build": { /* ... */ } }, "tags": ["feature"] } } ``` 3. **CLI validation** — `pnpm turbo boundaries` runs the check in <1 second without building anything ### Two layers, not one Both ESLint and Turborepo enforce the same five-tag rules, but for different reasons: | Layer | Runs | Sees | Exempts via | | --------------------------------- | ---------------- | ------------------------------------------------------- | ---------------------------------- | | ESLint `eslint-plugin-boundaries` | lint-time | Direct imports per file | `// @boundaries-ignore` comments | | Turborepo `boundaries` | build-graph time | Entire workspace dependency graph including transitives | None (graph-based, not per-import) | **Why both?** - **ESLint** provides fine-grained control (file-level exemptions) and immediate feedback during development - **Turborepo** catches transitive issues (e.g., feature reach through composition packages) and missing declarations **Enforcement** — CI runs both: `pnpm lint` (includes ESLint) and `pnpm turbo boundaries`. ## Consequences 1. **Two independent checks** — developers get immediate ESLint feedback and a final Turbo gate in CI 2. **Both must stay in sync** — when adding a new tag rule, update both: - `packages/core-eslint/base.js` (ESLint `eslint-plugin-boundaries` config) - Root `turbo.json` (`boundaries.tags` config) - Per-package `package.json` `turbo.tags` declaration 3. **Stricter than before** — Turborepo sees transitives; some patterns that pass ESLint may fail Turbo: - Example: `packages/cms` might try to use `@repo/` indirectly (not importing, but depending) - Solution: declare the dependency explicitly or restructure the import 4. **The five-tag model is a refinement, not a contradiction** — ADR-006 mentioned three tags; this ADR adds `core-composition` and `tooling` explicitly and moves them into the boundary enforcement 5. **`core-trpc`'s new tag** — previously thought of as `core`, now explicitly `core-composition` to match its transitive reach; any code assuming `core-trpc` is `core` must be updated ## Alternatives considered 1. **ESLint only** — Simple, immediate feedback. Downside: misses transitive issues; no build-graph validation. 2. **Turborepo only** — Catches everything eventually, but slower feedback cycle; less granular exemptions. 3. **Both in series** — ESLint first (fast), Turbo second (thorough). Chosen. 4. **Custom graph validator** — Over-engineered; Turborepo's built-in feature is stable and designed for this. ## Related - **ADR-006:** Vertical feature packages (the original three-tag model) - **ADR-009:** Integrations folder naming - **`packages/core-eslint/base.js`** — ESLint configuration - **Root `turbo.json`** — Turborepo configuration with boundaries rules - **`docs/architecture/overview.md`** — Package map and five-tag summary - **`docs/architecture/dependency-flow.md`** — Enforcement layers and rules - **`AGENTS.md`** — Per-package boundary documentation ## Timeline - **2026-05-04** — Turborepo boundaries added alongside existing ESLint enforcement - **CI integration** — `pnpm turbo boundaries` added to lint stage - **Documentation** — Updated AGENTS.md, overview.md, dependency-flow.md, vertical-feature-spec.md