Enforcing Layer Boundaries with a Robust Custom ESLint Rule
Learn how to write a custom ESLint rule that blocks imports from forbidden directories, handles relative paths, TypeScript aliases, and flat config, with example tests and performance tips.
18 Jul 2025, 16:01 UTC

The Problem: Architectural Drift
When a codebase grows, developers often forget the intended module boundaries. A common violation is a service module importing directly from a controller module, which can create circular dependencies and make the code harder to test. Documentation can be read, but it is easy to slip past a README and commit the offending import.
Moving the rule into the build pipeline turns the violation into a linting error that IDEs and CI can catch before the code is merged.
Why the AST Matters
ESLint parses JavaScript/TypeScript into an ESTree Abstract Syntax Tree. The ImportDeclaration node represents an import statement, while CallExpression nodes can represent require() calls. By visiting only ImportDeclaration nodes we avoid unnecessary work and keep the rule fast.
Building a Path‑Aware Rule
To determine whether an import is forbidden we need to resolve the import source relative to the file that contains it. The rule below:
const path = require("path");
module.exports = {
meta: {
type: "problem",
docs: {
description: "Disallow imports from specified directories",
recommended: false
},
schema: [
{
type: "object",
properties: {
forbidden: { type: "array", items: { type: "string" } },
resolver: { type: "string" }
},
additionalProperties: false
}
],
messages: {
forbiddenImport: "Import from '{{path}}' is forbidden in this layer."
}
},
create(context) {
const options = context.options[0] || {};
const forbidden = options.forbidden || [];
const resolverName = options.resolver || "node";
// Resolve import path to an absolute path using the chosen resolver.
const resolveImport = (importPath, sourceFile) => {
if (resolverName === "node") {
return path.resolve(path.dirname(sourceFile), importPath);
}
// Placeholder for custom resolver logic.
return path.resolve(path.dirname(sourceFile), importPath);
};
return {
ImportDeclaration(node) {
const sourceFile = context.getFilename();
const importSource = node.source.value;
const resolvedImport = resolveImport(importSource, sourceFile);
for (const forbiddenDir of forbidden) {
const resolvedForbidden = path.resolve(forbiddenDir);
if (resolvedImport.startsWith(resolvedForbidden)) {
context.report({
node,
messageId: "forbiddenImport",
data: { path: importSource }
});
break;
}
}
}
};
}
};
Key points:
- We use
path.resolveto handle both relative and absolute imports. - The rule accepts a list of forbidden directories via the rule’s options.
- Only
ImportDeclarationnodes are visited, keeping the rule lightweight.
Integrating with the Flat Config
With ESLint 8.0+ the eslint.config.js file replaces .eslintrc. Add the rule as a local plugin:
module.exports = [
{
plugins: {
"local": {
rules: {
"no-forbidden-imports": require("./rules/no-forbidden-imports")
}
}
},
rules: {
"local/no-forbidden-imports": [
"error",
{
forbidden: [
"./src/controllers",
"./src/utils"
]
}
]
}
}
];
Place the rule file in rules/no-forbidden-imports.js relative to the project root. The configuration above forbids any import that resolves inside src/controllers or src/utils when the importing file is located anywhere in the project.
Testing with RuleTester
ESLint’s RuleTester helps verify that the rule behaves as expected. The filename property is required for rules that depend on the file path.
const { RuleTester } = require("eslint");
const rule = require("./rules/no-forbidden-imports");
const tester = new RuleTester({
parserOptions: { ecmaVersion: 2020, sourceType: "module" }
});
tester.run("no-forbidden-imports", rule, {
valid: [
{
code: "import { foo } from './helpers';",
filename: "src/services/userService.js"
}
],
invalid: [
{
code: "import { UserController } from '../controllers/userController';",
filename: "src/services/userService.js",
errors: [{ messageId: "forbiddenImport", data: { path: "../controllers/userController" } }]
}
]
});
Running npm test (or the equivalent command) will execute the tests and confirm that the rule correctly flags forbidden imports.
Performance & Trade‑offs
- Speed: The rule only walks
ImportDeclarationnodes, so it adds negligible overhead even in large projects. - False Positives: If a project uses TypeScript path aliases, the resolver logic must match the alias resolution. The current implementation uses Node’s resolver; for alias support you can provide a custom
resolveroption that delegates toeslint-import-resolver-typescript. - Developer Friction: Set the rule to
warnduring rollout to surface issues without blocking commits, then switch toerroronce the team is comfortable. - Scope: The rule only handles ES module
importstatements. If your codebase also usesrequire(), you would need to add a visitor forCallExpressionthat checks forrequirecalls.
Rollback & Maintenance
If the rule causes a build failure, simply change its severity to off or remove it from the config. Because the rule logic is isolated, you can also comment out the resolver option to disable path resolution temporarily.
Actionable Next Steps
- Copy the rule file into your repo’s
rules/directory. - Add the plugin configuration to
eslint.config.jswith the directories you want to protect. - Run
npx eslint . --print-configto verify the rule is loaded. - Add
RuleTestertests to cover typical import patterns in your project. - Gradually increase severity from
warntoerroras the team adapts.
By embedding architectural constraints into the linting process, teams can catch violations early, reduce runtime surprises, and keep the codebase maintainable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.