eslint-plugin-import-boundaries
v0.8.1
Published
Enforce architectural boundaries with deterministic import paths
Maintainers
Readme
eslint-plugin-import-boundaries
One canonical import path per module, plus architectural boundary enforcement, with no module resolver. Path-form violations are auto-fixable.
Beta -- public API is still evolving. Core behavior is stable across real projects; option surface may continue to change. Pin your version.
Requires ESLint >=9.0.0 and Node >=18.
| Rule | What it does | Auto-fix |
| ------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------- |
| import-boundaries/enforce | Picks canonical path form and checks allow/deny rules between boundaries | Path form: yes. Boundary violations: no |
| import-boundaries/no-wildcard-barrel | Disallows export * in index files | No |
| import-boundaries/index-sibling-only | Index-file imports/re-exports must be direct siblings | No |
What this plugin does
Three things, designed to compose:
- Canonical import paths. For each importable target, exactly one spelling is considered correct. Non-canonical forms are reported and auto-fixed.
- Boundary enforcement. Enforces allow/deny rules across configured boundaries, with separate support for
import type. - Index-file discipline. Treats
index.ts/index.jsas directory interfaces, with two companion rules that keep them explicit and local.
With the common allow-list-only setup (see Allow/deny semantics), boundaries are deny-by-default: for example, if @ui does not include @data in allowImportsFrom, imports from @data into @ui are rejected.
This is intentionally opinionated: a single package where these concerns work together rather than being split across separate plugins.
Why not eslint-plugin-boundaries?
eslint-plugin-boundaries is mature and powerful. If you only need element/layer import policy, it is a great option.
Use eslint-plugin-import-boundaries when you also want:
- Canonical path enforcement with auto-fix. It normalizes allowed imports to one canonical form.
- Resolver-free operation. The core rule is path- and config-driven; no module resolver stack to wire and keep in sync.
- Companion index rules in the same package. Public-surface discipline (
no-wildcard-barrel+index-sibling-only) is part of the model.
Trade-off: no graph-based module analysis; it stays fast and config-driven.
The guiding principle is that the directory tree is the source of truth. Boundaries map to real dir paths (most specific / deepest match wins; nesting is supported), and index files are where a directory’s public surface is made explicit. Directory-based boundary maps also tend to be easier to audit in code review than long glob or pattern lists — though this is a style trade-off.
The most opinionated part: index files as directory interfaces
The plugin’s strongest architectural opinion is that directories expose public surface through index files.
Important nuance:
- The plugin does not verify that every directory physically has an index file.
- It effectively depends on that convention for correctness: canonical cross-boundary imports target boundary roots, so without threaded index-directory interfaces, imports are forced through locations that may not represent the intended public surface.
Why this matters:
- Cross-boundary imports are canonicalized to boundary roots (for example,
@domain, not@domain/internal/x). - That keeps boundary crossing explicit and funnels public API decisions into index files.
- The companion rules keep index files mechanical: no wildcard exports, no deep traversal.
This introduces small, intentional friction: publishing something across a boundary means making it explicit in the directory interface.
Setup
1: Install
npm install --save-dev eslint-plugin-import-boundaries2: Configure aliases
Alias style is the long-term target. crossBoundaryStyle: 'absolute' is deprecated and scheduled for removal in v0.9.0.
{
"compilerOptions": {
"paths": {
"@domain": ["./src/domain/index.ts"],
"@domain/*": ["./src/domain/*"],
"@ui": ["./src/ui/index.ts"],
"@ui/*": ["./src/ui/*"],
"@data": ["./src/data/index.ts"],
"@data/*": ["./src/data/*"]
}
}
}3: Define boundaries
defineConfig and defineBoundaries are typed identity helpers for editor autocomplete and type checking.
// boundaries.config.ts
import type { RuleOptions } from 'eslint-plugin-import-boundaries';
import { defineConfig } from 'eslint-plugin-import-boundaries';
const boundariesConfig: RuleOptions = defineConfig({
rootDir: 'src',
boundaries: [
{
identifier: '@domain',
dir: 'domain',
alias: '@domain',
// No allow/deny lists => deny all.
},
{
identifier: '@ui',
dir: 'ui',
alias: '@ui',
allowImportsFrom: ['@domain'],
},
{
identifier: '@data',
dir: 'data',
alias: '@data',
allowImportsFrom: ['@domain'],
},
],
});
export default boundariesConfig;// boundaries.ts (optional split form)
import type { BoundaryConfig } from 'eslint-plugin-import-boundaries';
import { defineBoundaries } from 'eslint-plugin-import-boundaries';
export const boundaries: BoundaryConfig[] = defineBoundaries([
{ identifier: '@domain', dir: 'domain', alias: '@domain' },
{
identifier: '@ui',
dir: 'ui',
alias: '@ui',
allowImportsFrom: ['@domain'],
},
{
identifier: '@data',
dir: 'data',
alias: '@data',
allowImportsFrom: ['@domain'],
},
]);These helpers exist to make config authoring in TypeScript easier and safer (autocomplete, option hints, typo detection on keys like allowTypeImportsFrom, nestedPathFormat, etc.). They add no runtime behavior beyond returning the same value.
// eslint.config.js
import importBoundaries from 'eslint-plugin-import-boundaries';
import boundariesConfig from './boundaries.config.ts';
export default [
{
plugins: { 'import-boundaries': importBoundaries },
rules: {
'import-boundaries/enforce': ['error', boundariesConfig],
'import-boundaries/no-wildcard-barrel': 'error',
'import-boundaries/index-sibling-only': 'error',
},
},
];If your runtime does not load TypeScript config files directly, use one of these patterns:
- Keep boundaries in JavaScript (
boundaries.config.js) and import that fromeslint.config.js. - Keep boundaries in TypeScript, then transpile to JavaScript and import the emitted
.jsfile. - Use
eslint.config.tswhen your toolchain/runtime supports it end-to-end.
4: Add an index.ts to each boundary root
A boundary's index.ts defines its public surface — the exports visible to other boundaries. But the same convention applies at every level: each subdirectory's index.ts defines what the rest of that boundary may use from it. Cross-boundary imports resolve to the boundary alias (@domain), which in turn resolves through the boundary root's index.ts. Same-boundary imports deeper in the tree are canonicalized to paths like @domain/feature, which resolve through src/domain/feature/index.ts. Without index files threaded through the directory tree, the canonical path targets don't exist on disk.
// src/domain/index.ts
export { User } from './user.ts';
export type { UserId } from './ids.ts';Bootstrap tip. Threading index files through an existing codebase is the hardest part of adopting this plugin. It is significantly easier at project start than on a codebase that has grown without them — every directory that gets imported from needs one, not just boundary roots. Budget accordingly.
The no-wildcard-barrel rule blocks export *, so generic barrel generators (barrelsby, create-ts-index) won't produce the form this plugin expects — they emit wildcard re-exports. The fastest paths today:
- Use editor-assisted codegen or a one-shot codemod script to generate explicit named re-exports across the tree in one pass.
- Or write a small one-shot Node script per project to walk each boundary, parse exports, and emit named re-export lines.
- After bootstrap, maintenance is one line per public export — small cost, high visibility.
TypeScript types
The package exports types so configs stay honest without relying on comments:
| Type | What it represents |
| ------------------------ | -------------------------------------------------------------------------------------------------- |
| RuleOptions | Full options object passed to import-boundaries/enforce (rootDir, boundaries, toggles, etc.) |
| BoundaryConfig | One boundary entry inside RuleOptions.boundaries |
| BarrelFileRuleOptions | Options for no-wildcard-barrel / index-sibling-only (mostly barrelFileName) |
| ImportBoundariesRules | Typed ESLint flat-config rule entries for this plugin |
| ImportBoundariesPlugin | Typed shape of the plugin default export |
satisfies Partial<ImportBoundariesRules> is a useful ergonomics pattern in eslint.config.ts to get rule-level type checking without widening the object type.
Helpers:
defineConfig(options: RuleOptions)-- identity helper forRuleOptionsdefineBoundaries(boundaries: readonly BoundaryConfig[])-- identity helper for just theboundariesarray
You can also skip the helpers and use import type { RuleOptions } ... + satisfies RuleOptions directly -- same typing outcome.
Enforce
import-boundaries/enforce does two jobs:
- Canonical path-form enforcement (fixable).
- Boundary allow/deny enforcement (non-fixable).
What it visits
- ES
importandimport typedeclarations - Dynamic
import('...')expressions require('...')calls (CommonJS interop, value imports only)export ... from '...'andexport * from '...'re-exports
The index rules (no-wildcard-barrel, index-sibling-only) check ES module syntax only.
Auto-fix highlights
Path-form violations are deterministic, so the fixer rewrites them to the canonical form. Boundary allow/deny violations are intentionally not fixable — there is no automatic right answer.
| Before | After (lint --fix) | Why |
| ----------------------------------------- | -------------------------------- | ----------------------------------------------------- |
| import { User } from '../../domain' | import { User } from '@domain' | Climb past maxRelativeDepth → boundary alias |
| import { User } from 'src/domain' | import { User } from '@domain' | src/... style normalized to alias |
| import { User } from '@/domain' | import { User } from '@domain' | rootDirAlias input normalized to canonical boundary |
| import { User } from '@domain/entities' | import { User } from '@domain' | Cross-boundary subpath collapses to boundary root |
Cross-boundary imports
Canonical form is the boundary root, no cross-boundary subpath:
import { UserRepo } from '@data'; // ✓
import { UserRepo } from '@data/user-repo'; // ✗ cross-boundary subpath
import { UserRepo } from 'src/data'; // ✗ non-canonical (prefer `@data`)Same-boundary imports
Canonical choice depends on relationship:
- Same directory ->
./sibling - Nearby cousin (within
maxRelativeDepth) ->../cousin - Distant target (beyond
maxRelativeDepth) ->@boundary/segment - Own boundary root from inside that boundary -> forbidden (ancestor-directory cycle risk)
maxRelativeDepth defaults to 1.
Ancestor-directory import
Importing your own boundary root from inside that boundary is always reported (not auto-fixable):
// inside src/data/...
import { something } from '@data'; // ✗crossBoundaryStyle status
- Supported values:
'alias' | 'absolute' - Omitted by default: inferred per file extension
.ts,.tsx,.mts,.cts->'alias'.js,.jsx,.mjs,.cjs, and others ->'absolute'
'absolute'is deprecated and will be removed inv0.9.0
In inferred mode, TypeScript files require aliases on all boundaries.
Index rules
no-wildcard-barrel
Disallows wildcard re-exports in index files.
// BAD
export * from './user-service';
export * as User from './user-service';
// GOOD
export { UserService } from './user-service.ts';index-sibling-only
In index files, import/re-export sources must be direct siblings:
// BAD
import { foo } from '../parent';
export { bar } from './subdir/deep/thing';
import { baz } from '@data';
// GOOD
export { UserService } from './user-service.ts';
export { Routes } from './routes'; // sibling directory indexComposing all three rules
Use skipIndexFiles: true on enforce so file sets are disjoint:
rules: {
'import-boundaries/enforce': ['error', { ...boundariesConfig, skipIndexFiles: true }],
'import-boundaries/no-wildcard-barrel': 'error',
'import-boundaries/index-sibling-only': 'error',
}Boundary rules
Allow/deny semantics
- Only
allowImportsFrom: deny-all default, then allow listed identifiers - Only
denyImportsFrom: allow-all default, then deny listed identifiers - Both: allow list applies, deny list overrides
- Neither: deny all (strictest)
Type-only imports
{
identifier: '@ui',
dir: 'ui',
alias: '@ui',
allowImportsFrom: ['@domain'],
allowTypeImportsFrom: ['@data'],
}import type { Row } from '@data'; // ✓ type-only escape hatch
import { pool } from '@data'; // ✗ value importNested boundaries
Nested boundaries resolve to the most specific match. Rules do not inherit automatically — a file inside data/internal/ resolves to @data-internal only. Even if @data allows @domain, @data-internal must still list @domain in its own allowImportsFrom; parent allow/deny lists do not cascade.
boundaries: [
{
identifier: '@data',
dir: 'data',
alias: '@data',
allowImportsFrom: ['@domain'],
},
{
identifier: '@data-internal',
dir: 'data/internal',
alias: '@data-internal',
allowImportsFrom: ['@domain', '@data'],
nestedPathFormat: 'relative',
},
];nestedPathFormat applies when a nested boundary imports from its parent:
'relative'-> force../...'alias'-> force alias path'inherit'(default) -> follow current cross-boundary style
Config reference
Rule options
| Option | Type | Default | Purpose |
| ------------------------ | ----------------------- | ------------------------------------------------ | -------------------------------------------------------------------------- |
| rootDir | string | 'src' | Root dir for resolving boundary dir paths |
| boundaries | BoundaryConfig[] | required | Boundary definitions |
| crossBoundaryStyle | 'alias' \| 'absolute' | inferred | Per-file inference when omitted |
| defaultSeverity | 'error' \| 'warn' | rule-level | Default boundary-violation severity |
| enforceBoundaries | boolean | true | false skips allow/deny checks but still enforces path form |
| allowUnknownBoundaries | boolean | false | Allow targets outside all configured boundaries |
| maxRelativeDepth | number | 1 | Max ../ segments before canonicalizing to boundary path |
| skipIndexFiles | boolean | false | Skip enforce on index files |
| fileExtensions | string[] | ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] | Recognized code imports |
| rootDirAlias | string | '@' | Accept <alias>/... as root-dir import input (@/foo -> <rootDir>/foo) |
Boundary properties
| Property | Required | Type | Purpose |
| ---------------------- | ------------------------------ | ------------------------------------ | -------------------------------------------------- |
| identifier | yes | string | Name used in allow/deny lists and diagnostics |
| dir | yes | string | Directory under rootDir |
| alias | required for alias-style usage | string | Canonical boundary import prefix (@domain) |
| allowImportsFrom | no | string[] | Allowed source boundary identifiers |
| denyImportsFrom | no | string[] | Denied source boundary identifiers |
| allowTypeImportsFrom | no | string[] | Type-only allow list |
| nestedPathFormat | no | 'alias' \| 'relative' \| 'inherit' | Parent-import style override for nested boundaries |
| severity | no | 'error' \| 'warn' | Per-boundary severity override |
rootDirAlias and naming guidance
rootDirAlias exists for project-root aliases like @/foo (or ~/foo if configured).
These are accepted as input and normalized to canonical paths.
Collision guidance:
- Do not reuse plain package-like names for boundary aliases.
- Prefer explicit boundary aliases like
@domain,@ui,@data. - If you want
~/foosource-root imports, userootDirAlias: '~'. This does not make~servera boundary alias format.
Test-file config pattern
Common setup: skip allow/deny checks in tests while still enforcing canonical path form. Keep it maintainable by using shared globs and a base options object:
const testFileGlobs = ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**/*.ts'];
const enforceBase = boundariesConfig;
const enforceForTests = { ...enforceBase, enforceBoundaries: false };
export default [
{
files: testFileGlobs,
rules: {
'import-boundaries/enforce': ['error', enforceForTests],
},
},
{
files: ['src/**/*.ts'],
ignores: testFileGlobs,
rules: {
'import-boundaries/enforce': ['error', enforceBase],
},
},
];Error messages
enforce
| Message | Meaning | Auto-fix |
| ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------- |
| Expected '@data' but got '../../data' | Wrong path form | Yes |
| Cannot import from '@data' into '@ui': ... | Boundary violation: @data is the imported boundary, @ui is the file's boundary; that direction is denied by allow/deny lists. | No |
| Cannot import from ancestor directory '@data' | Importing your own boundary root from inside that boundary | No |
| Cannot import from '...' - path is outside all configured boundaries | Target resolves outside every configured boundary (and allowUnknownBoundaries is false) | No |
| When 'crossBoundaryStyle' is omitted, TypeScript files use alias paths; every boundary must have an 'alias'. Missing for: ... | Inferred-style config error (TS file but boundaries lack alias) | No |
no-wildcard-barrel
| Message | Meaning | Auto-fix |
| --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -------- |
| Wildcard export 'export * from "..."' is not allowed in index files. Use explicit named exports instead: 'export { Name } from "..."' | export * in an index file | No |
| Wildcard namespace export 'export * as Name from "..."' is not allowed in index files. Use explicit named exports instead. | export * as Foo from '...' in an index file | No |
index-sibling-only
| Message | Meaning | Auto-fix |
| ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | -------- |
| Index files may only import from direct siblings. '...' is not a sibling import. Use './filename.ext' (flat file) or './dirname' (directory) format. | Index file references a non-sibling path (parent traversal, nested, or cross-boundary) | No |
Compatibility
Non-goal: CommonJS require() is not checked — only ES import/export statements and dynamic import() expressions.
Compatible:
import/no-unresolvedimport/no-duplicatesimport/ordereslint-plugin-simple-import-sort
Potentially conflicting path-style enforcers (disable if present):
eslint-plugin-no-relative-import-pathseslint-plugin-absolute-importsimport/no-relative-packageseslint-plugin-boundaries
License
ISC
Contributing
Issues and PRs are welcome.
