turbo.json's boundary config already allows `feature -> feature`, and the cross-feature event system depends on it — a consumer must import the publisher's event contract from `@repo/<publisher>`. But the ESLint boundaries config, ADR-010, and AGENTS.md still declared `feature -> [core, tooling]`, contradicting turbo.json and the shipped code (marketing-pages imports @repo/auth). Align all three to turbo.json: a feature may import another feature's published public exports. Internals stay sealed by the `exports` map, and cross-feature behaviour still flows through IEventBus.
7.6 KiB
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
exportsmap boundaries
However, ESLint has two intrinsic limitations:
-
No transitive enforcement — If
core-trpcimportscore-api(which imports feature routers), ESLint sees only the direct edgecore-trpc→core-api. The transitive reach into features is invisible. A package can declare@repo/feature-xas a dependency without ever importing it directly. -
No declared-dep enforcement — A
package.jsondependency that doesn't match an actual import goes undetected. Conversely, apackage.jsondependency 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.jsondeclarations
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-trpcimports@repo/core-api(the tRPC app router)core-apiimports feature routers from@repo/<feature>/api- Therefore,
core-trpctransitively depends on features through theAppRoutertype - This violates ADR-006's "core → NOT feature" rule if we treat
core-trpcas plaincore - Solution: tag
core-trpcascore-compositionto make the transitive reach explicit and allowed
Implementation
- Root
turbo.jsondeclares boundary rules as aboundaries.tagsconfig:{ "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
featuretag's allow-list includesfeature. A feature package may depend on another feature's published public exports — the@repo/<feature>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'sexportsmap seals its internals (only.,./ui,./api,./cms,./di/bind-*are reachable), and cross-feature behaviour still flows throughIEventBus— a feature never imports and invokes another feature's use cases directly.
-
Per-package
turbo.jsondeclares the package's tag:// packages/blog/turbo.json { "extends": ["../../../turbo.json"], "tasks": { /* ... */ } } // package.json { "turbo": { "tasks": { "build": { /* ... */ } }, "tags": ["feature"] } } -
CLI validation —
pnpm turbo boundariesruns 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
- Two independent checks — developers get immediate ESLint feedback and a final Turbo gate in CI
- Both must stay in sync — when adding a new tag rule, update both:
packages/core-eslint/base.js(ESLinteslint-plugin-boundariesconfig)- Root
turbo.json(boundaries.tagsconfig) - Per-package
package.jsonturbo.tagsdeclaration
- Stricter than before — Turborepo sees transitives; some patterns that pass ESLint may fail Turbo:
- Example:
packages/cmsmight try to use@repo/<dep>indirectly (not importing, but depending) - Solution: declare the dependency explicitly or restructure the import
- Example:
- The five-tag model is a refinement, not a contradiction — ADR-006 mentioned three tags; this ADR adds
core-compositionandtoolingexplicitly and moves them into the boundary enforcement core-trpc's new tag — previously thought of ascore, now explicitlycore-compositionto match its transitive reach; any code assumingcore-trpciscoremust be updated
Alternatives considered
- ESLint only — Simple, immediate feedback. Downside: misses transitive issues; no build-graph validation.
- Turborepo only — Catches everything eventually, but slower feedback cycle; less granular exemptions.
- Both in series — ESLint first (fast), Turbo second (thorough). Chosen.
- 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 summarydocs/architecture/dependency-flow.md— Enforcement layers and rulesAGENTS.md— Per-package boundary documentation
Timeline
- 2026-05-04 — Turborepo boundaries added alongside existing ESLint enforcement
- CI integration —
pnpm turbo boundariesadded to lint stage - Documentation — Updated AGENTS.md, overview.md, dependency-flow.md, vertical-feature-spec.md