Skip to content
Fallow home
All docs pages

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:

  • coverage reports source files that are outside every zone.
  • calls bans specific callees per zone.

For preset descriptions, output formats, and suppression, see Architecture boundaries.

Presets

Fallow has four built-in presets for common architecture patterns:

PresetZonesDescription
layered4Classic N-tier: presentation, application, domain, infrastructure
hexagonal3Ports and adapters: adapters, ports, domain
feature-sliced6Feature-Sliced Design: strict downward-only imports
bulletproof4Bulletproof 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:

  1. Read tsconfig.json from the project root. JSONC with comments and trailing commas is supported.
  2. Read compilerOptions.rootDir.
  3. Remove a leading ./ prefix and any trailing /.
  4. Reject ., .., and absolute paths for security.
  5. Use src if 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:

FieldTypeDescription
namestringZone identifier used in rules
patternsstring[]Glob patterns relative to the project root, or relative to root when set
autoDiscoverstring[]Directories where each immediate child directory becomes a separate zone named parent/child
rootstring?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:

FieldTypeDescription
fromstringThe importing zone
allowstring[]Zones that from may import from
allowTypeOnlystring[]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 allow list 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 type qualifier: 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 on Bar still gives a violation.
  • Plain value imports (import { Foo } from '...').
  • Side-effect imports (import '...'). These run the target at runtime.

Semantics:

  • allowTypeOnly is independent of allow. 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 from as 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:

FieldTypeDescription
requireAllFilesbooleanReport every analyzed source file that matches no zone as a boundary_coverage_violations finding (default false)
allowUnmatchedstring[]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 requireAllFiles is true. 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-violation rule.
  • 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:

FieldTypeDescription
fromstringZone where files may not make matching calls
calleestring | 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.
    • fetch matches only fetch, never myfetch.
    • A trailing object.* matches any member: child_process.* matches child_process.exec.
    • A leading *.member matches any object: *.innerHTML matches el.innerHTML.
  • Import provenance: patterns match through imports. child_process.* covers import { execSync } from "node:child_process", the bare child_process specifier, namespace imports, and default imports.
  • Direct callee paths only, including optional chaining (cp?.exec() matches like cp.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.

See also