Boundaries enforce import direction between architectural zones. A zone groups files by glob pattern; a rule declares which zones may import from which. Violations appear as boundary-violation issues in fallow dead-code output.
Two optional sections extend zones beyond import direction: coverage reports source files that fall outside every zone, and calls bans specific callees per zone.
For preset descriptions, output formats, and suppression, see Architecture boundaries.
Fallow ships four built-in presets for common architecture patterns:
| Preset | Zones | Description |
|---|---|---|
layered | 4 | Classic N-tier: presentation, application, domain, infrastructure |
hexagonal | 3 | Ports and adapters: adapters, ports, domain |
feature-sliced | 6 | Feature-Sliced Design: strict downward-only imports |
bulletproof | 4 | Bulletproof React: app, features, shared, server |
{
"boundaries": {
"preset": "bulletproof"
}
}When a preset is active, fallow reads compilerOptions.rootDir from tsconfig.json to determine the source root for zone patterns. Preset zones become {rootDir}/{zone}/**.
The detection logic:
tsconfig.json from the project root (supports JSONC with comments and trailing commas)compilerOptions.rootDir./ prefix and any trailing /., .., and absolute paths for securitysrc if the field is missing, the file doesn't exist, or the value is rejected{
"compilerOptions": {
"rootDir": "./lib"
}
}
With the hexagonal preset, this produces zones:
lib/adapters/**
lib/ports/**
lib/domain/**
rootDir detection only applies to presets. Custom zones use patterns exactly as written.
Define zones manually when presets don't match your architecture. Each zone has:
| Field | Type | Description |
|---|---|---|
name | string | Zone identifier used in rules |
patterns | string[] | Glob patterns relative to the project root, or relative to root when set |
autoDiscover | string[] | Directories whose immediate child directories become separate zones named parent/child |
root | string? | Optional subtree scope; patterns and autoDiscover paths are resolved relative to this root |
{
"boundaries": {
"zones": [
{ "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
{ "name": "data", "patterns": ["src/db/**", "src/api/**"] },
{ "name": "shared", "patterns": ["src/lib/**", "src/utils/**"] }
]
}
}Zone classification rules:
Fallow warns when a zone matches zero files. This usually means the glob pattern doesn't match your directory structure. Run fallow list --boundaries to check file counts per zone.
Use autoDiscover for feature-module boundaries where every immediate child directory should become its own zone. Rules can still reference the logical parent; fallow expands them to the discovered child zones.
{
"boundaries": {
"zones": [
{ "name": "app", "patterns": ["src/app/**"] },
{ "name": "features", "patterns": ["src/features/**"], "autoDiscover": ["src/features"] },
{ "name": "shared", "patterns": ["src/shared/**"] }
],
"rules": [
{ "from": "app", "allow": ["features", "shared"] },
{ "from": "features", "allow": ["shared"] }
]
}
}If src/features/auth and src/features/billing exist, fallow list --boundaries shows features/auth and features/billing, and sibling feature imports are checked as cross-zone imports. Explicit child rules, such as from: "features/auth", override generated parent rules regardless of rule order.
When the zone also has patterns, discovered child zones are matched first and top-level files inside the auto-discover directory fall back to the parent zone. The parent fallback rule automatically allows its discovered children, so a src/features/index.ts barrel can re-export feature modules without surfacing features → features/<child> violations, while non-barrel top-level files such as src/features/types.ts still obey the parent features rule. Generated child rules keep the original allow list exactly, so sibling-feature isolation is preserved. Omit patterns from the zone when you want only discovered child directories classified and top-level files left unrestricted.
Rules define which zones may import from which. Each rule has:
| Field | Type | Description |
|---|---|---|
from | string | The importing zone |
allow | string[] | Zones that from may import from |
allowTypeOnly | string[] | Zones that from may type-only-import from even when not in allow (optional) |
{
"boundaries": {
"zones": [
{ "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
{ "name": "data", "patterns": ["src/db/**", "src/api/**"] },
{ "name": "shared", "patterns": ["src/lib/**", "src/utils/**"] }
],
"rules": [
{ "from": "ui", "allow": ["shared"] },
{ "from": "data", "allow": ["shared"] },
{ "from": "shared", "allow": [] }
]
}
}Rule semantics:
allow list means the zone is isolated -- no cross-zone imports permittedStart with rules for your lowest-level zones (the ones that should import from nothing) and work upward.
TypeScript projects sometimes need type-level contracts between modules that must not have runtime dependencies on each other. A plugin system, for example, may declare a contribution interface in one feature that consumers in other features depend on at the type level only:
// features/metrics-panel/extension-api.ts
export interface MetricsPanelContribution { items?: MetricItemProvider[]; }
// features/extra-metrics/index.ts
import type { MetricsPanelContribution } from '#/features/metrics-panel/extension-api';
The import is fully erased at compile time, so it carries no runtime coupling. To admit it without opening the full allow list to the target zone, add allowTypeOnly:
{
"boundaries": {
"rules": [
{
"from": "features/extra-metrics",
"allow": [],
"allowTypeOnly": ["features/metrics-panel"]
}
]
}
}What allowTypeOnly admits:
import type { Foo } from '...'import type * as ns from '...'type qualifier: import { type Foo } from '...'export type { Foo } from '...'What it does NOT admit:
import { type Foo, Bar }) -- the runtime dependency on Bar still fires a violationimport { Foo } from '...')import '...') -- these run the target at runtimeSemantics:
allowTypeOnly is independent of allow. A type-only edge is admitted if its target zone is in EITHER list.When both preset and zones/rules are specified, fallow merges them:
from as a preset rule replace the preset ruleUse the hexagonal preset but put your domain code in src/core instead of src/domain:
{
"boundaries": {
"preset": "hexagonal",
"zones": [
{ "name": "domain", "patterns": ["src/core/**"] }
]
}
}This keeps adapters and ports from the hexagonal preset but replaces the domain zone pattern with src/core/**. All three rules remain unchanged.
Zones only constrain files they match; anything unmatched is unrestricted. To catch source files that silently fall outside every zone, enable coverage:
| Field | Type | Description |
|---|---|---|
requireAllFiles | boolean | Report every analyzed source file that matches no zone as a boundary_coverage_violations finding (default false) |
allowUnmatched | string[] | Glob patterns for intentionally unzoned paths; invalid globs are rejected at config load |
{
"boundaries": {
"zones": [
{ "name": "domain", "patterns": ["src/domain/**"] }
],
"coverage": {
"requireAllFiles": true,
"allowUnmatched": ["src/generated/**"]
}
}
}Semantics:
requireAllFiles is true; the default keeps unmatched files unrestricted, preserving pre-coverage behavior.boundary-violation rule severity and suppression token.Prefer allowUnmatched over file-level suppression comments for generated code: the config keeps the exemption in one reviewable place.
To ban specific calls from a zone (for example, keeping a domain layer free of process execution and logging), add a forbidden-call policy:
| Field | Type | Description |
|---|---|---|
from | string | Zone whose files may not make matching calls |
callee | string | string[] | Forbidden callee pattern(s), matched segment-aware |
{
"boundaries": {
"zones": [
{ "name": "domain", "patterns": ["src/domain/**"] }
],
"calls": {
"forbidden": [
{ "from": "domain", "callee": "child_process.*" },
{ "from": "domain", "callee": ["console.*", "process.exit"] }
]
}
}
}Each matching call reports as a boundary_call_violations finding naming the written callee, the matched pattern, and the zone.
Matching semantics:
fetch matches only fetch (never myfetch), a trailing object.* matches any member (child_process.* matches child_process.exec), and a leading *.member matches any object (*.innerHTML matches el.innerHTML).child_process.* covers import { execSync } from "node:child_process", the bare child_process specifier, namespace imports, and default imports alike.cp?.exec() matches like cp.exec()). Aliased or re-bound callees (const run = cp.exec; run()), computed members, and injected dependencies (this.client.exec()) are not followed.coverage.requireAllFiles to force zoning first.* are rejected at config load, and a rule pointing at a zone that matches no files warns at analysis time.Severity and suppression: forbidden-call findings inherit the boundary-violation rule (default error) and suppression token; the rule-id-shaped boundary-call-violation and boundary-call-violations tokens are accepted as aliases. Any of these tokens suppresses the whole boundary family on that line or file.
For a staged rollout, start with "rules": { "boundary-violation": "warn" } while you triage existing forbidden calls, then switch back to "error" to enforce. Note this also softens import-direction violations, since the boundary family shares one rule.
Use fallow list --boundaries to see the expanded configuration after preset expansion and merging. This is the fastest way to verify your config is correct.
Boundaries: 4 zones, 4 rules
Zones:
app 3 files src/app/**
features 12 files src/features/**
shared 8 files src/components/**, src/hooks/**, src/lib/**, ...
server 4 files src/server/**
Rules:
app → features, shared, server
features → shared, server
server → shared
shared (isolated, no imports allowed)
For scripting or MCP tools, use JSON output:
fallow list --boundaries --format json --quiet
Run fallow list --boundaries first when debugging boundary violations. It shows actual zone patterns, file counts, and resolved rules. Misconfigured globs become obvious before running analysis.
Preset zone patterns are flat (src/<zone>/**), which doesn't work when packages have separate source directories. Define zones explicitly for each package:
{
"boundaries": {
"zones": [
{ "name": "web-ui", "patterns": ["apps/web/src/components/**", "apps/web/src/pages/**"] },
{ "name": "web-data", "patterns": ["apps/web/src/api/**", "apps/web/src/hooks/**"] },
{ "name": "shared-ui", "patterns": ["packages/ui/src/**"] },
{ "name": "shared-utils", "patterns": ["packages/utils/src/**"] }
],
"rules": [
{ "from": "web-ui", "allow": ["web-data", "shared-ui", "shared-utils"] },
{ "from": "web-data", "allow": ["shared-utils"] },
{ "from": "shared-ui", "allow": ["shared-utils"] },
{ "from": "shared-utils", "allow": [] }
]
}
}In tRPC apps, the server router typically imports from feature modules to compose sub-routers. With the bulletproof preset, this causes server -> features violations.
The cleanest approach is to suppress the router composition file:
// fallow-ignore-file boundary-violation
import { authRouter } from '../features/auth/router'
import { billingRouter } from '../features/billing/router'
export const appRouter = router({
auth: authRouter,
billing: billingRouter,
})Alternatively, add features to the server rule:
{
"boundaries": {
"preset": "bulletproof",
"rules": [
{ "from": "server", "allow": ["shared", "features"] }
]
}
}Widening the server rule allows all server files to import from features, not just the router. Prefer inline suppression for surgical exceptions.
Test files often need to import from any zone. Add a test zone that sits above the preset layers:
{
"boundaries": {
"preset": "bulletproof",
"zones": [
{ "name": "test", "patterns": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/__tests__/**"] }
],
"rules": [
{ "from": "test", "allow": ["app", "features", "shared", "server"] }
]
}
}Since zones use first-match classification, place more specific patterns (like test file globs) before broader zone patterns. When using a preset with custom zones, custom zones are appended after preset zones. If your test files live inside zone directories (e.g., src/features/auth/auth.test.ts), the preset's features zone pattern will match first. Move the test patterns into an explicit zone list that comes before the preset zones, or use ignorePatterns to exclude test files from boundary analysis entirely.