Boundaries
Keep your architecture intact as the codebase grows. Fallow reports each import that crosses a layer it should not cross. Start from a built-in preset or define your own zones and rules.
Boundaries stop imports that break your architecture, for example UI code that imports the database layer. You group files into zones with glob patterns. A rule says which zones a zone may import from. Fallow reports each forbidden import as a boundary-violation issue in fallow dead-code output.
Two optional sections add checks beyond import direction:
For preset descriptions, output formats, and suppression, see Architecture boundaries.
Presets
Fallow has 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"
}
}tsconfig rootDir detection
When a preset is active, fallow reads compilerOptions.rootDir from tsconfig.json to find the source root for zone patterns. Preset zones become {rootDir}/{zone}/**.
Fallow finds the root in these steps:
- Read
tsconfig.jsonfrom the project root. JSONC with comments and trailing commas is supported. - Read
compilerOptions.rootDir. - Remove a leading
./prefix and any trailing/. - Reject
.,.., and absolute paths for security. - Use
srcif the field is missing, the file does not exist, or the value is rejected.
{
"compilerOptions": {
"rootDir": "./lib"
}
}
With the hexagonal preset, this config gives these zones:
lib/adapters/**
lib/ports/**
lib/domain/**
rootDir detection applies only to presets. Custom zones use patterns exactly as written.
Custom zones
When no preset matches your architecture, define zones yourself. Each zone has these fields:
| 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 where each immediate child directory becomes a separate zone named parent/child |
root | string? | Optional subtree. Fallow resolves patterns and autoDiscover paths 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:
- A file belongs to the first zone with a matching pattern (first match wins).
- Files that match no zone are unrestricted. They can import from any zone, and any zone can import them.
- Zone order is important. Put more specific patterns before broader ones.
Fallow warns when a zone matches no analyzed file (boundary zone '<zone>' matched 0 files). Usually the glob pattern does not match your directory structure. Run fallow list --boundaries to see the file count of each zone.
Auto-discovered zones
Use autoDiscover to make each feature module its own zone, without one zone entry per feature. Every immediate child directory becomes a zone. Rules can still name the parent zone, and 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. Fallow checks imports between sibling features as cross-zone imports. An explicit child rule, such as from: "features/auth", overrides the generated parent rule, in any rule order.
When the zone also has patterns, fallow matches the discovered child zones first. Top-level files in the auto-discover directory then belong to the parent zone.
The parent rule automatically allows its discovered children. A src/features/index.ts barrel can therefore re-export feature modules without features → features/<child> violations. Other top-level files, such as src/features/types.ts, still follow the parent features rule. Generated child rules keep the original allow list exactly, so sibling features stay isolated.
To classify only the discovered child directories and leave top-level files unrestricted, remove patterns from the zone.
Custom rules
A rule says which zones a zone may import from. Each rule has these fields:
| Field | Type | Description |
|---|---|---|
from | string | The importing zone |
allow | string[] | Zones that from may import from |
allowTypeOnly | string[] | Zones that from may import types from, also when they are 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:
- Self-imports are always allowed. Files in the same zone can import each other.
- No rule entry means the zone is unrestricted. It can import from any zone.
- Empty
allowlist means the zone is isolated. It cannot import from other zones. - Only zones with an explicit rule entry have restrictions.
- Re-export chains: a named or default import through a barrel file is judged against the zone of the module that declares the symbol. The finding names the barrel in
via_path. Namespace and side-effect imports are judged by the direct target. See re-export chains. - Every analyzed file: rules also apply to a file that no entry point reaches.
Start with rules for your lowest-level zones (the zones that should import from nothing). Then add rules for the zones above them.
Type-only imports across boundaries
Some TypeScript modules must share types but must not depend on each other at runtime. For example, a plugin system can declare a contribution interface in one feature. Other features then import only that type:
// 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 compiler removes this import, so there is no runtime dependency. To allow it without adding the target zone to allow, add allowTypeOnly:
{
"boundaries": {
"rules": [
{
"from": "features/extra-metrics",
"allow": [],
"allowTypeOnly": ["features/metrics-panel"]
}
]
}
}What allowTypeOnly admits:
- Whole-declaration type imports:
import type { Foo } from '...' - Namespace type imports:
import type * as ns from '...' - Inline-type imports where every named specifier has the
typequalifier:import { type Foo } from '...' - Type-only re-exports:
export type { Foo } from '...'
What it does NOT admit:
- Mixed-specifier imports where at least one specifier is a value (
import { type Foo, Bar }). The runtime dependency onBarstill gives a violation. - Plain value imports (
import { Foo } from '...'). - Side-effect imports (
import '...'). These run the target at runtime.
Semantics:
allowTypeOnlyis independent ofallow. Fallow allows a type-only import if its target zone is in either list.- The default value is an empty list. Without the field, fallow behaves as before the field existed.
- No preset (Layered, Hexagonal, FeatureSliced, Bulletproof) sets the field. Type-only crossings are opt-in per rule.
Merging presets with custom config
When you set preset together with zones or rules, fallow merges them:
- Zones with the same name as a preset zone replace the preset zone fully.
- Rules with the same
fromas a preset rule replace the preset rule. - Fallow adds new zones and rules after the preset zones and rules. A zone that replaces a preset zone also moves to the position of your zone in the list.
Example: override a preset zone
Use the hexagonal preset, but keep your domain code in src/core instead of src/domain:
{
"boundaries": {
"preset": "hexagonal",
"zones": [
{ "name": "domain", "patterns": ["src/core/**"] }
]
}
}This config keeps adapters and ports from the hexagonal preset and replaces the domain zone pattern with src/core/**. All three rules stay the same.
Boundary coverage
Zones restrict only the files they match. A file outside every zone has no restrictions, and fallow does not tell you about it. To find these files, 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 paths that you leave outside all zones on purpose. Fallow rejects invalid globs when it loads the config |
{
"boundaries": {
"zones": [
{ "name": "domain", "patterns": ["src/domain/**"] }
],
"coverage": {
"requireAllFiles": true,
"allowUnmatched": ["src/generated/**"]
}
}
}Semantics:
- Coverage runs only when
requireAllFilesistrue. By default, unmatched files stay unrestricted, as before coverage existed. - Coverage checks every analyzed file, also a file that no entry point reaches.
- Findings use the severity and the suppression token of the
boundary-violationrule. - Combine coverage with forbidden calls. A file outside all zones then cannot escape a call policy.
For generated code, use allowUnmatched instead of file-level suppression comments. The config keeps the exception in one place that reviewers can see.
Forbidden calls
To ban specific calls in a zone, add a forbidden-call policy. For example, you can keep process execution and logging out of a domain layer:
| Field | Type | Description |
|---|---|---|
from | string | Zone where files may not make matching calls |
callee | string | string[] | One or more forbidden callee patterns, matched per segment |
{
"boundaries": {
"zones": [
{ "name": "domain", "patterns": ["src/domain/**"] }
],
"calls": {
"forbidden": [
{ "from": "domain", "callee": "child_process.*" },
{ "from": "domain", "callee": ["console.*", "process.exit"] }
]
}
}
}Fallow reports each matching call as a boundary_call_violations finding. The finding names the callee as written, the matched pattern, and the zone.
Matching semantics:
- Per segment: matching compares whole name segments. It does not match substrings or globs.
fetchmatches onlyfetch, nevermyfetch.- A trailing
object.*matches any member:child_process.*matcheschild_process.exec. - A leading
*.membermatches any object:*.innerHTMLmatchesel.innerHTML.
- Import provenance: patterns match through imports.
child_process.*coversimport { execSync } from "node:child_process", the barechild_processspecifier, namespace imports, and default imports. - Direct callee paths only, including optional chaining (
cp?.exec()matches likecp.exec()). Fallow does not follow aliased or re-bound callees (const run = cp.exec; run()), computed members, or injected dependencies (this.client.exec()). - Zoned files only: rules apply only to files in a zone, also a file that no entry point reaches. Files outside all zones are unrestricted. To make sure every file is in a zone, combine with
coverage.requireAllFiles. - One finding per unique callee path per file: when several rules match, the first matching rule in config order wins. After you fix the first call site, the next run reports the next call to the same callee.
- Fallow rejects patterns that match nothing useful, such as a bare
*, when it loads the config. A rule for a zone that matches no files gives a warning during analysis.
Severity and suppression: forbidden-call findings use the boundary-violation rule (default error) and its suppression token. Fallow also accepts the aliases boundary-call-violation and boundary-call-violations. Each of these tokens suppresses all boundary findings on that line or in that file.
For a staged rollout, set "rules": { "boundary-violation": "warn" } while you triage existing forbidden calls. Then set it back to "error" to enforce them. This also makes import-direction violations warnings, because all boundary findings share one rule.
Inspecting boundaries
Run fallow list --boundaries to see the final configuration after preset expansion and merging. This is the fastest way to check your config.
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 scripts or MCP tools, use JSON output:
fallow list --boundaries --format json --quiet
When you debug boundary violations, run fallow list --boundaries first. It shows the real zone patterns, file counts, and resolved rules. You see a wrong glob before you run the analysis.
Common patterns
Monorepo with per-package boundaries
Preset zone patterns are flat (src/<zone>/**). They do not work when packages have separate source directories. Define zones 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": [] }
]
}
}tRPC apps with server-to-features imports
In tRPC apps, the server router usually imports sub-routers from feature modules. With the bulletproof preset, this gives server -> features violations.
The cleanest fix is to suppress the file that combines the routers:
// 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,
})You can also add features to the server rule:
{
"boundaries": {
"preset": "bulletproof",
"rules": [
{ "from": "server", "allow": ["shared", "features"] }
]
}
}A wider server rule lets all server files import from features, not only the router. For one exception, use inline suppression.
Adding a test zone alongside a preset
Test files often import from any zone. Add a test zone 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"] }
]
}
}The first matching zone wins. With a preset, fallow puts the preset zones first and adds your custom zones after them. This example therefore works only for test files outside the preset zone directories, such as src/__tests__/**. For a test file inside a zone directory (for example src/features/auth/auth.test.ts), the features zone of the preset matches first.
To put the test zone first, redeclare every preset zone after it. A zone with the same name as a preset zone replaces it, and fallow keeps the order of your list. The preset rules stay active:
{
"boundaries": {
"preset": "bulletproof",
"zones": [
{ "name": "test", "patterns": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/__tests__/**"] },
{ "name": "app", "patterns": ["src/app/**"] },
{ "name": "features", "patterns": ["src/features/**"], "autoDiscover": ["src/features"] },
{
"name": "shared",
"patterns": [
"src/components/**", "src/hooks/**", "src/lib/**", "src/utils/**", "src/utilities/**",
"src/providers/**", "src/shared/**", "src/types/**", "src/styles/**", "src/i18n/**"
]
},
{ "name": "server", "patterns": ["src/server/**"] }
],
"rules": [
{ "from": "test", "allow": ["app", "features", "shared", "server"] }
]
}
}A replacing zone uses only the patterns that you give. Copy all the preset patterns, as in this example. Run fallow list --boundaries to check the zone order. You can also use ignorePatterns to exclude test files from the analysis fully.