Files
agentic-dev-template/docs/decisions/adr-010-turbo-boundaries.md
Danijel Martinek f3182537c1 fix(boundaries): allow feature-to-feature imports of public exports
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.
2026-05-21 14:30:37 +02:00

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 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-trpccore-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-trpccore-apifeature-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:

  • appapps/web-next, apps/web-tanstack, apps/cms, apps/storybook
  • corepackages/core-shared, core-ui (pure foundation, no transitive feature reach)
  • core-compositionpackages/core-api, core-cms, core-trpc (composition or transitively reach features)
  • featurepackages/auth, blog, media, marketing-pages, navigation
  • toolingpackages/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/<feature>/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:
    {
      "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/<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'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.

  1. Per-package turbo.json declares the package's tag:

    // packages/blog/turbo.json
    { "extends": ["../../../turbo.json"], "tasks": { /* ... */ } }
    // package.json
    { "turbo": { "tasks": { "build": { /* ... */ } }, "tags": ["feature"] } }
    
  2. CLI validationpnpm 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/<dep> 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.
  • 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 integrationpnpm turbo boundaries added to lint stage
  • Documentation — Updated AGENTS.md, overview.md, dependency-flow.md, vertical-feature-spec.md