Trovella Wiki

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.

RuleFromCannot ImportWhy
no-circularAny packageAny circular chainCircular dependencies break Turborepo build ordering
no-leaf-to-higherutils, validators, design-tokensAny higher layer (app, service, infra, core)Leaf packages must be dependency-free
no-core-to-service-or-appdbapi, mcp, apps/Core must not depend on service or app logic
no-core-to-infradbai, auth, cache, searchCore must not depend on infrastructure wrappers
no-infra-to-service-or-appai, auth, cache, logger, searchapi, mcp, apps/Infrastructure must not depend on business logic
no-service-to-appapi, mcpapps/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.

SDKAllowed 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 import statements 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:

  1. Decide which layer it belongs to
  2. Add it to the appropriate from and to path patterns in .dependency-cruiserrc.mjs
  3. Create its eslint.config.js with restrictedImports("@repo/new-package")
  4. If it wraps an external SDK, add the SDK to SDK_BOUNDARIES in packages/config-eslint/restrictions.js
  5. Run pnpm dep-cruise to validate the graph
  6. Run pnpm lint to verify zero import violations

Common Violations

ViolationRoot CauseFix
no-core-to-infra error@repo/db importing a logger or auth helperMove the helper to @repo/utils or pass it as a parameter
no-leaf-to-higher error@repo/utils importing a Drizzle typeDefine the type locally or move it to @repo/validators
no-infra-to-service-or-app error@repo/auth importing a tRPC typeExtract the shared type to @repo/validators
SDK import in wrong packageDirect import { Anthropic } from "@anthropic-ai/sdk" in @repo/apiUse @repo/ai wrapper instead

On this page