Configure architecture boundaries and zones
Architecture boundaries for TypeScript and JavaScript. Fallow reports each import that crosses a layer. Start from a built-in preset or define your own zones.
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 architecture 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. A single * matches inside one directory, so use ** for nested files. See Path globs |
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 or new expression as a boundary_call_violations finding. The finding names the callee as written, the matched pattern, and the zone. A new expression has the prefix new in callee, for example new Date for the pattern Date.
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 call site: each call and each
newexpression gets its own finding, soDate()andnew Date()on two lines give two findings. When several rules match, the first matching rule in config order wins. Afallow-ignore-next-linecomment covers only the call site on its next line. - 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.