Layer Hierarchy
The 5-layer package DAG -- what each layer contains, allowed import directions, and how violations are caught by both ESLint and dependency-cruiser.
All @repo/* packages are assigned to one of five layers. Imports are only allowed to flow downward -- from app toward leaf. Upward imports are blocked by two independent tools: ESLint no-restricted-imports (editor-time) and dependency-cruiser (CI).
The Five Layers
app apps/web
|
service @repo/api, @repo/mcp
|
infra @repo/ai, @repo/auth, @repo/cache, @repo/logger, @repo/search
|
core @repo/db
|
leaf @repo/utils, @repo/validators, @repo/design-tokens
Arrows indicate the allowed import direction. Each layer can import from the layers below it, but never from layers above.
Layer Definitions
App Layer
apps/web -- the Next.js application. This is the only consumer-facing entry point. It can import from any layer below.
Service Layer
@repo/api (tRPC routers) and @repo/mcp (MCP tool server). These packages define the application's API surface. They can import from infra, core, and leaf layers. They cannot import from the app layer or from each other (cross-service imports are forbidden by the circular dependency rule).
Infrastructure Layer
@repo/ai, @repo/auth, @repo/cache, @repo/logger, @repo/search. These packages wrap external SDKs and provide internal APIs. Each external SDK is imported through exactly one wrapper package. They can import from core and leaf layers.
Core Layer
@repo/db -- Drizzle schemas, migrations, the tenant context system, and query helpers. The core layer can only import from leaf packages. It cannot import from infrastructure packages (no @repo/ai, @repo/auth, etc.) or from higher layers.
Leaf Layer
@repo/utils, @repo/validators, @repo/design-tokens. Pure utility packages with zero internal dependencies. They cannot import from any other @repo/* package.
Forbidden Import Rules
dependency-cruiser enforces the hierarchy through six forbidden rules in .dependency-cruiserrc.mjs. Each rule is severity error and blocks CI.
| Rule | From | Cannot Import | Why |
|---|---|---|---|
no-circular | Any package | Any circular chain | Circular dependencies break Turborepo build ordering |
no-leaf-to-higher | utils, validators, design-tokens | Any higher layer (app, service, infra, core) | Leaf packages must be dependency-free |
no-core-to-service-or-app | db | api, mcp, apps/ | Core must not depend on service or app logic |
no-core-to-infra | db | ai, auth, cache, search | Core must not depend on infrastructure wrappers |
no-infra-to-service-or-app | ai, auth, cache, logger, search | api, mcp, apps/ | Infrastructure must not depend on business logic |
no-service-to-app | api, mcp | apps/ | Services must not depend on the application shell |
SDK Boundary Enforcement
External SDKs have an additional constraint: they can only be imported inside their designated wrapper package. This is enforced by ESLint no-restricted-imports via packages/config-eslint/restrictions.js.
| SDK | Allowed Wrapper |
|---|---|
@anthropic-ai/sdk | @repo/ai |
@google/genai | @repo/ai |
better-auth / better-auth/client | @repo/auth, @repo/web |
better-auth/react | @repo/web |
drizzle-orm/pg-core | @repo/db |
drizzle-orm/node-postgres | @repo/db |
@upstash/redis | @repo/cache |
pino | @repo/logger |
typesense | @repo/search |
Each package's eslint.config.js calls restrictedImports("@repo/package-name") to get the deny list for SDKs it is not allowed to import. This gives instant editor feedback -- the violation appears as a red squiggle before the developer even saves the file.
Why Two Tools
ESLint and dependency-cruiser serve different feedback loops:
- ESLint sees one file at a time and gives instant editor feedback. It catches direct
importstatements but cannot follow re-exports or transitive dependency chains. - dependency-cruiser sees the entire import graph. It catches dynamic
import()expressions, re-exports through intermediate modules, and transitive chains. But it only runs in CI (minutes, not instant).
Using both provides defense-in-depth: if an AI agent bypasses the ESLint restriction (e.g., by importing through a re-export), dependency-cruiser catches it at CI time.
Adding a New Package
When adding a new @repo/* package to the monorepo:
- Decide which layer it belongs to
- Add it to the appropriate
fromandtopath patterns in.dependency-cruiserrc.mjs - Create its
eslint.config.jswithrestrictedImports("@repo/new-package") - If it wraps an external SDK, add the SDK to
SDK_BOUNDARIESinpackages/config-eslint/restrictions.js - Run
pnpm dep-cruiseto validate the graph - Run
pnpm lintto verify zero import violations
Common Violations
| Violation | Root Cause | Fix |
|---|---|---|
no-core-to-infra error | @repo/db importing a logger or auth helper | Move the helper to @repo/utils or pass it as a parameter |
no-leaf-to-higher error | @repo/utils importing a Drizzle type | Define the type locally or move it to @repo/validators |
no-infra-to-service-or-app error | @repo/auth importing a tRPC type | Extract the shared type to @repo/validators |
| SDK import in wrong package | Direct import { Anthropic } from "@anthropic-ai/sdk" in @repo/api | Use @repo/ai wrapper instead |
Related Pages
- Dependency Graph Validation -- full dependency-cruiser configuration reference
- Identity & Access -- Tenant Isolation Enforcement -- how the layer hierarchy protects tenant isolation specifically
ADR-013: Architecture Enforcement
Automated quality system for AI-generated code -- dependency-cruiser, Knip, jscpd, sonarjs, fitness tests, and AI-assisted review skills.
Dependency Graph Validation
dependency-cruiser configuration reference -- forbidden rules, orphan detection, resolution options, and how to run it locally.