How to Enforce Monorepo Package Dependency Rules with Turborepo Boundaries
Declaratively define architectural boundaries between packages in code and automatically enforce them in CI
Anyone who has operated a monorepo has eventually encountered a PR review like this: "Wait, why is this frontend package directly importing an internal backend module?" And the answer is almost always: "It was just sitting right there..."
I ran into this problem when I first set up our team's monorepo. I drew architecture diagrams in Notion and explicitly wrote in the contribution guide that "this package must not reference that package." Even so, one or two violations would slip through in PR reviews, and new team members who didn't know the rules would just import ../../packages/internal-auth/src/utils without a second thought. Recently, another variable has been added to the mix: AI code agents generating cross-package imports without context has become a hot topic across teams. Agents that generate code far faster than humans are increasingly disregarding dependency rules altogether.
The Boundaries feature, released experimentally in Turborepo 2.4, makes this a problem caught by "code and CI" rather than "a reviewer's eyes." By the end of this article, you'll be able to declaratively define dependency rules in turbo.json and set up a CI pipeline that automatically checks package boundaries across the entire monorepo.
Core Concepts
Why Boundaries Break Down in Monorepos
The appeal of a monorepo is that packages coexist on the same filesystem. But that's also its trap. Even without declaring a dependency in package.json, it's technically perfectly fine to directly import a file like ../../packages/internal-auth/src/secret.ts via a relative path. Since the TypeScript compiler resolves types just fine as long as the path is valid, developers end up in a situation where "it just works."
In a pnpm workspace-based monorepo, package paths are registered in pnpm-workspace.yaml, and each package's package.json should declare other packages as dependencies. The baseline check in Turborepo Boundaries operates on exactly these package.json dependencies declarations. In other words, importing without a declaration in package.json, or directly referencing a file outside a package's directory, is detected as a violation.
| Violation Type | Description |
|---|---|
| Package Manager Workspace Violation | Importing a package not declared in package.json dependencies |
| Package Directory Escape | Directly referencing a file outside the package boundary via a relative path |
These two checks alone can catch a significant number of "it was just sitting there" import patterns.
Declaring Custom Architecture Rules with the Tag System
Going one step beyond baseline violation detection, the tag system lets you express your team's architectural philosophy in code. You attach tags to each package via turbo.json, and declare "which tags can depend on which tags" in the root turbo.json.
// Root turbo.json
{
"$schema": "https://turbo.build/schema.json",
"boundaries": {
"tags": {
"public": {
"dependencies": {
"deny": ["internal"]
}
},
"frontend": {
"dependencies": {
"allow": ["ui", "utils"]
}
}
}
}
}// packages/ui/turbo.json
{
"extends": ["//"],
"tags": ["internal", "ui"]
}
allowvsdenydifference:allowis a whitelist approach that blocks all dependencies except those listed.denyis a blacklist approach that selectively prohibits only specific tags.allowis natural for strict layer design, whiledenyis natural when isolating specific packages.
Transitive Dependency Checking
Honestly, this was the most impressive part. It doesn't just check direct imports — it traces the entire dependency chain. If A imports B, and B imports C which has a denied tag, a violation is detected at A as well.
A (public) → B → C (internal) ← violation detected at A too!This structurally closes the loophole of "I didn't import it directly, so I should be fine."
When you run turbo boundaries, violations are reported in this format:
$ turbo boundaries
✗ packages/design-system
Cannot depend on "packages/internal-helpers" (tag: "internal")
Rule: tag "public" cannot depend on tag "internal"
Found 1 boundary violation.It clearly identifies which package violated which rule, so tracking down the cause takes almost no time.
Practical Application
Example 1: Separating public/internal Package Layers
This is a pattern you can use right away when you want to distinguish between packages to be exposed externally — like a design system — and packages meant only for internal implementation. If you've operated a monorepo, you've probably experienced an internal helper function quietly creeping into a public-facing package and causing headaches during refactoring.
// packages/design-system/turbo.json
{
"extends": ["//"],
"tags": ["public"]
}// packages/internal-helpers/turbo.json
{
"extends": ["//"],
"tags": ["internal"]
}// Root turbo.json
{
"$schema": "https://turbo.build/schema.json",
"boundaries": {
"tags": {
"public": {
"dependencies": {
"deny": ["internal"]
}
}
}
}
}| Configuration Element | Role |
|---|---|
design-system → tags: ["public"] |
Declares this as an externally exposed package |
internal-helpers → tags: ["internal"] |
Declares this as an internal-only package |
deny: ["internal"] |
Violation when a public package depends on an internal package |
Example 2: Enforcing frontend / backend Layers
When Node.js-only modules get mixed into a browser bundle, it leads directly to runtime errors. With this rule in place, you can block it before the build.
// apps/web/turbo.json
{
"extends": ["//"],
"tags": ["frontend"]
}// packages/api-server/turbo.json
{
"extends": ["//"],
"tags": ["backend"]
}// Root turbo.json
{
"$schema": "https://turbo.build/schema.json",
"boundaries": {
"tags": {
"backend": {
"dependents": {
"deny": ["frontend"]
}
}
}
}
}
dependenciesvsdependentsdifference:dependenciesis a rule about "what this tag can depend on," whiledependentsis the reverse — "who can depend on this tag." In the example above, settingdependents.deny: ["frontend"]onbackendblocks the direction of frontend referencing backend.
If apps/web accidentally imports packages/api-server, this message is displayed:
$ turbo boundaries
✗ apps/web
Cannot depend on "packages/api-server" (tag: "backend")
Rule: tag "backend" dependents cannot include tag "frontend"
Found 1 boundary violation.You can discover "oh, there was an import like this" before a PR is merged.
Example 3: Enforcing Layered Architecture
This is a pattern for dividing packages into layers by role and preventing lower layers from referencing higher layers. The same approach can be applied to enforce the layer principles of Feature-Sliced Design (FSD, a frontend architecture methodology based on layer and slice principles) at the code level.
The role each layer plays is as follows:
- core: Pure utilities with no external dependencies, common type definitions
- shared: Common components and hooks reused across multiple features
- feature: Packages organized around specific domain functionality
- app: Deployable applications
// Root turbo.json — Layered Architecture Rules
{
"$schema": "https://turbo.build/schema.json",
"boundaries": {
"tags": {
"core": {
"dependencies": {
"allow": []
}
},
"shared": {
"dependencies": {
"allow": ["core"]
}
},
"feature": {
"dependencies": {
"allow": ["core", "shared"]
}
},
"app": {
"dependencies": {
"allow": ["core", "shared", "feature"]
}
}
}
}
}// packages/core-utils/turbo.json
{
"extends": ["//"],
"tags": ["core"]
}// apps/my-app/turbo.json
{
"extends": ["//"],
"tags": ["app"]
}Because it uses the allow whitelist approach, even when a new layer is added, dependencies must be explicitly declared. This structurally prevents reverse dependencies and circular dependencies.
Example 4: CI Pipeline Integration
To actually enforce boundary checks across the team, integrating them into CI is essential. A check that only runs locally will eventually be forgotten. Our team ran it locally for the first two months, only to discover much later that nobody was running it during busy sprints. Only after putting it in CI did "this check exists" truly take hold across the whole team.
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
check-boundaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
run: pnpm install
- name: Check boundaries
run: pnpm turbo boundariesIn local development environments, you can activate the Boundaries feature by adding "boundaries": true to the root turbo.json. This setting is a feature activation flag; boundary checks are performed by running the turbo boundaries command directly or calling it from a CI step.
// Root turbo.json — Enable Boundaries Feature
{
"$schema": "https://turbo.build/schema.json",
"boundaries": true
}Pros and Cons Analysis
The biggest benefit I felt after introducing this to our team was that "architecture rules live in the code." Not in a Slack thread or a Notion doc, but in a turbo.json file. New team members who clone the repository can immediately understand what role each package plays and which dependency directions are allowed. The main concern was that it's still Experimental — it's true that a routine of checking release notes on every minor upgrade was added.
Advantages
| Item | Details |
|---|---|
| Declarative Rules | Architecture rules are embedded in code via turbo.json, making rules self-evident without separate documentation |
| Transitive Checking | Tracks not just direct imports but indirect dependency chains, closing off workarounds |
| Built-in Integration | Checks can be run directly in Turborepo without an ESLint plugin, keeping configuration complexity low |
| Incremental Adoption | Start with a single line "boundaries": true, then add tag rules one by one as needed |
| CI Automation | Adding turbo boundaries to CI allows you to block violating PRs from being merged |
| Defense Against AI Code Generation | Statically detects the pattern of AI coding agents arbitrarily creating cross-package imports |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Experimental Status | Still an experimental feature as of April 2026, with possible API changes | Recommend checking release notes on minor version upgrades. Following the RFC #9435 thread lets you catch change notices early |
| Maturity vs. Nx | Nx's enforce-module-boundaries has a mature ecosystem at the ESLint level |
If fine-grained file-level rules are needed, using both tools in parallel is an option |
| No File-Level Boundary Support | Currently only supports package-level boundaries | ESLint import/no-restricted-paths can supplement intra-package file-level rules |
| Tag Management Overhead | As packages grow, maintaining tags in each turbo.json becomes manual work |
Keeping tags minimal and documenting naming conventions makes management much easier |
| JS/TS Only | Currently centered on the JavaScript/TypeScript ecosystem | In polyglot environments, Dependency Cruiser (a standalone tool that visualizes dependency graphs and detects violations with custom rules) can be used alongside |
Caution with Experimental Features: Since
turbo boundariesis not yet stable, it is strongly recommended to check release notes even on minor version upgrades. Following the RFC #9435 thread on Turborepo's official GitHub lets you catch change notices early.
The Most Common Mistakes in Practice
Our team actually fell into the second mistake when we first introduced it. We had crafted the tag rules carefully, but when the check results didn't match expectations, it took us a long time to realize the package.json declarations were missing.
-
Over-segmenting tags from the start: If you divide layers into 10 or more from the beginning, management overhead increases rapidly. Starting with a simple structure at the level of
public/internalorfrontend/backendand expanding when actual need arises is far more sustainable. Putting too much effort into the initial design can result in a situation where the team can't maintain the rules. -
Trusting tags alone without
package.jsondeclarations: The baseline check in Boundaries (Package Manager Workspace violations) operates onpackage.jsondependenciesdeclarations. Independent of tag rules, packages you actually use must also be declared independenciesfor the check to work correctly. If you've added tags but the baseline check isn't catching what you expect, checkpackage.jsonfirst. -
Checking locally only without CI integration: Even if
turbo boundariesruns fine locally, without CI it will quickly be forgotten during a busy sprint. Going all the way to adding a step in GitHub Actions when first introducing it ensures the rules naturally take hold across the whole team. The moment a check becomes a local opt-in, it becomes a rule that "exists but doesn't."
Closing Thoughts
A few months after the introduction, the biggest change has been in PR review conversations. Comments like "shouldn't this package not be used here?" have disappeared, replaced by CI failure logs. Since the tooling catches what reviewers used to have to catch, we can focus on more important things in reviews.
Turborepo Boundaries is a tool that takes a monorepo's architecture rules out of the team's collective memory and turns them into code in the form of turbo.json. It's still Experimental, but the baseline violation detection alone provides sufficient immediate value.
Three steps you can start with right now:
-
Activate baseline checks: Add
"boundaries": trueto the rootturbo.jsonand runturbo boundariesto first identify violations hiding in your current monorepo. The number may be higher than you expect. -
Design tag rules: Pick the single clearest boundary you want to enforce (
publicvsinternal, orfrontendvsbackend) and add tags and rules to the rootturbo.jsonand the relevant packages'turbo.json. Just one pair is enough to start. -
CI integration: Add a
turbo boundariesexecution step to GitHub Actions to complete the pipeline so that PRs with violations cannot be merged, naturally instilling the rules across the whole team. A check that only runs locally will inevitably be forgotten.
Next article: A step-by-step guide to implementing Feature-Sliced Design (FSD) layer and slice principles with package structure and naming conventions in an actual pnpm monorepo
References
- Turborepo Boundaries Official Documentation
- Turborepo 2.4 Release Notes
- RFC #9435: Boundaries Design Discussion (GitHub)
- RFC #7460: Tags and scopes (enforce module boundaries)
- Turborepo turbo.json Configuration Reference
- Nx enforce-module-boundaries Official Documentation
- Feature-Sliced Design: Frontend Monorepo Architecture Guide