Architectural Boundary Enforcement with ESLint Custom Rules
Learn how to create a focused ESLint rule that uses the AST Visitor pattern to enforce architectural boundaries, with notes on configuration, testing, and limitations.
04 Jan 2026, 19:46 UTC

Problem: Architectural Drift
In large JavaScript or TypeScript projects, architectural boundaries—such as preventing UI code from importing database models or restricting service layers to specific utilities—are often documented but ignored. Manual review misses violations, leading to unintended coupling and harder maintenance.
Useful takeaway: a custom ESLint rule can statically analyze the Abstract Syntax Tree (AST) to flag forbidden imports or calls, providing fast feedback in editors and CI pipelines.
Requirements
A rule intended for production use should satisfy:
- Static analysis: detect violations without executing code.
- Acceptable overhead: avoid noticeable slowdown in linting runs.
- Precision: minimize false positives to prevent alert fatigue.
Smallest Suitable Design
ESLint rules are implemented via the Visitor pattern: you export a create function that returns an object whose keys are AST node selectors (e.g., ImportDeclaration). ESLint invokes the corresponding handler only when it encounters that node type, avoiding a full‑tree walk.
For a simple layer‑access restriction, the ImportDeclaration node is sufficient because it captures static import statements.
Example Rule: Block UI‑to‑DB Imports
The rule below checks files under src/ui and reports an error if they import from src/db. It uses context.getFilename() and the path module to work across operating systems, and reads a project‑level setting (import/resolver) to support Webpack or TypeScript aliases.
// no-ui-to-db-imports.js
const path = require('path');
module.exports = {
meta: {
type: 'problem',
docs: { description: 'Prevent UI layer from accessing DB layer' },
messages: {
forbiddenImport: 'UI files must not import from the DB layer.'
},
schema: []
},
create(context) {
const filename = context.getFilename();
// Resolve project root from eslint config (fallback to cwd)
const projectRoot = context.settings?.['import/resolver']?.webpack?.config?.context
|| process.cwd();
const relative = path.relative(projectRoot, filename);
return {
ImportDeclaration(node) {
// Apply only to files that live under src/ui
if (!relative.startsWith('src/ui' + path.sep)) {
return;
}
const source = node.source.value;
// Simple string check; for alias resolution you would need to
// integrate with the project's import resolver (e.g., via eslint-import-resolver).
if (source.includes('src/db') || source.startsWith('@db')) {
context.report({ node, messageId: 'forbiddenImport' });
}
}
};
}
};
Trust and Data Boundaries
The rule relies on data supplied by ESLint:
- Parser output: The AST is produced by the parser configured for ESLint (Espree by default, but configurable to @babel/eslint-parser, typescript-eslint-parser, etc.). If the parser does not understand a language feature, the rule may miss nodes.
- Context object: Provides
getFilename()and optionalsettings(e.g., fromeslint.config.js) that the rule uses to locate the file relative to the project root and to access alias configuration.
No external system state is trusted; the rule only evaluates the static source.
Operational Checks
Use ESLint’s RuleTester to validate the rule in isolation. The tester works with both CommonJS and ES module imports; adjust the import style to match your project’s module system.
// test/no-ui-to-db-imports.test.js
const { RuleTester } = require('eslint');
const rule = require('../no-ui-to-db-imports');
const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2020 } });
ruleTester.run('no-ui-to-db-imports', rule, {
valid: [
{ code: 'import { Button } from \"./ui/button\";', filename: 'src/ui/App.js' },
{ code: 'import { utils } from \"@utils/helpers\";', filename: 'src/ui/App.js' }
],
invalid: [
{
code: 'import { UserModel } from \"../db/models\";',
filename: 'src/ui/App.js',
errors: [{ messageId: 'forbiddenImport' }]
}
]
});
To verify the rule in a real codebase:
- Add the rule to your
eslint.config.js(flat config) underpluginsandrules. - Run
eslint --print-config src/ui/App.jsto confirm the rule appears with the expected severity. - Execute
eslint src/ui/App.jsand check for the reported error. - If you implement a fix function, run
eslint --fix src/ui/App.jsand inspect the transformed file.
Failure Modes and Limitations
- Broad selectors: Using high‑level selectors like
Program:exitto re‑scan the entire tree can increase linting time, especially in large files. Prefer the most specific node selector that matches the pattern you need. - Alias resolution: A simple
node.source.valuestring check fails when the project uses Webpack aliases (@api/...) or TypeScript path mappings. Accurate resolution requires integrating with the project’s import resolver (e.g., viaeslint-import-resolver-webpackor reading thetsconfig.jsonpathsfield and passing that data throughcontext.settings). - Dynamic imports: The
ImportDeclarationnode only matches staticimportstatements. To catchimport()calls, also listen forCallExpressionwhere the callee isImport. - Parser version mismatch: If the project uses a newer JavaScript syntax (e.g., top‑level await) but the parser is outdated, the AST may be malformed, causing the rule to miss nodes or throw.
Conditions That Would Redesign the Rule
- Transitive dependency checks: When you need to enforce that a module does not indirectly depend on another layer (e.g., UI → utils → db), a single‑file AST approach is insufficient. In such cases consider a dedicated dependency‑graph tool like
dependency-cruiser,madge, or a custom script that builds a module graph. - Frequently changing boundaries: If the forbidden paths vary per team or per module, move the list into the rule’s options schema so it can be overridden in
eslint.config.jsrather than hardcoding. - Need for automatic fixes: If you want ESLint to rewrite imports to a permitted alias, you must implement a
fixfunction that returns a replacement range; ensure the fix handles all edge cases (e.g., mixed named/default imports, comments).
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.