Enforcing Architectural Boundaries with Custom ESLint Rules
Stop relying on documentation to maintain project architecture. Learn how to write custom ESLint rules using AST visitors to programmatically enforce boundary constraints.
05 Mar 2026, 16:56 UTC

The Problem: Architectural Drift
In large-scale JavaScript or TypeScript projects, architectural boundaries often exist only in documentation. You might decide that the /ui layer should never import directly from the /db layer to prevent leaking database schemas into the view. However, as a team grows, a developer will eventually add import { UserSchema } from '@/db/schema' inside a React component. This is architectural drift.
The takeaway: Instead of relying on manual code reviews to catch boundary violations, you can automate these constraints by writing custom ESLint rules that treat architectural violations as build errors.
How ESLint Sees Your Code
ESLint does not treat your code as a string of text; it uses a parser (typically Espree) to convert your code into an Abstract Syntax Tree (AST). An AST is a tree representation of the structural components of your code. For example, an import statement is not just text—it is an ImportDeclaration node containing a source (the path) and specifiers (the named imports).
Custom rules use a visitor pattern. You tell ESLint, "Whenever you encounter an ImportDeclaration node, run this specific function." This allows you to inspect the source path of every import and decide if it violates your project's boundaries.
Example: Blocking Cross-Layer Imports
Imagine a project structure where /services should not import from /controllers. To enforce this, you can create a local ESLint plugin. Below is a simplified implementation of a rule that flags these imports.
// eslint-local-rules/no-controller-imports.js
module.exports = {
meta: {
type: 'problem',
docs: { description: 'Prevent services from importing controllers' },
messages: { forbiddenImport: 'Services must not import from controllers to avoid circular dependencies.' },
},
create(context) {
return {
ImportDeclaration(node) {
const importPath = node.source.value;
const filename = context.getFilename();
// Check if the current file is in the services directory
if (filename.includes('/services/')) {
// Check if the import path points to the controllers directory
if (importPath.includes('/controllers/')) {
context.report({
node,
messageId: 'forbiddenImport'
});
}
}
},
};
},
};
Running and Verifying the Rule
To apply this rule, you must include it in your ESLint configuration. If using a local plugin setup (like eslint-plugin-local-rules), your configuration would look like this:
// .eslintrc.json
{
"plugins": ["local-rules"],
"rules": {
"local-rules/no-controller-imports": "error"
}
}
Execution: Run the lint command from your project root using the terminal:
npx eslint "src/**/*.ts"
Permissions: Ensure the user running the command has read access to the source files and the local rules directory.
Expected Result: Any file located in /services/ that contains an import from /controllers/ will trigger a build error, preventing the code from being merged.
Trade-offs and Maintenance
Custom rules are powerful, but they introduce a maintenance overhead. Because they rely on the AST, a major update to the JavaScript language or the parser (e.g., moving from one version of TypeScript to another) can occasionally change how nodes are structured, potentially breaking your rule.
Furthermore, there is a risk of false positives. If you use a simple .includes('/controllers/') check, you might accidentally flag a package named @company/controllers-utils which is actually allowed. To mitigate this, you should use more precise regular expressions or path resolution logic via path.resolve().
Verification and Validation
To ensure your rule works as intended without guessing, use the following workflow:
- AST Explorer: Visit
astexplorer.net, select theespreeparser, and paste your code. This lets you see exactly which node types (likeCallExpressionorVariableDeclarator) you need to target. - Negative Testing: Create a temporary file that should fail the rule and one that should not. Run ESLint specifically against those files to verify the rule's precision.
- Config Check: Run
npx eslint --print-config path/to/file.jsto confirm that the custom rule is actually being applied to the target file.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.