Skip to content
Notifications
Clear all

Just built a custom lint rule for our team - sharing the config

2 Posts
2 Users
0 Reactions
0 Views
(@consulting_contractor_mike)
Reputable Member
Joined: 4 months ago
Posts: 186
Topic starter   [#23385]

Having recently led a migration to a monorepo with stricter compliance requirements, our team found that existing ESLint plugins weren't adequately enforcing our new architectural boundaries and internal API usage patterns. We decided to author a custom ESLint rule to prevent direct imports between our loosely-coupled "domain modules" within the `packages/` directory, mandating the use of a defined public API interface instead.

The rule, `no-cross-domain-imports`, leverages ESLint's node traversal capabilities to analyze import declarations. The core logic checks if an import source string resolves to a path that crosses a designated domain boundary, and if that import is targeting a private directory (e.g., `src/internal/`). Here's the essential part of the rule definition in `lib/rules/no-cross-domain-imports.js`:

```javascript
module.exports = {
meta: { ... },
create(context) {
const currentFilePath = context.getPhysicalFilename();
const currentDir = path.dirname(currentFilePath);

// Configuration: map of domain root paths
const domainRoots = ['packages/domain-a', 'packages/domain-b', 'packages/shared'];

function getDomain(filePath) {
const root = domainRoots.find(r => filePath.includes(r));
return root || null;
}

return {
ImportDeclaration(node) {
const importSource = node.source.value;
if (!importSource.startsWith('.')) return; // Ignore node_modules

const resolvedImport = resolveImportPath(importSource, currentDir);
const currentDomain = getDomain(currentFilePath);
const importDomain = getDomain(resolvedImport);

if (currentDomain && importDomain && currentDomain !== importDomain) {
// Check if importing from a private subpath
if (resolvedImport.includes('/internal/')) {
context.report({
node,
message: `Cross-domain import of private module. Domain '${currentDomain}' cannot directly import '${resolvedImport}'. Use the public API from '${importDomain}/public' instead.`
});
}
}
}
};
}
};
```

The accompanying ESLint configuration in our root `.eslintrc.js` is straightforward but requires the path resolution helper:

```javascript
const path = require('path');

module.exports = {
plugins: ['internal'],
rules: {
'internal/no-cross-domain-imports': ['error', {
domainRoots: [
path.resolve(__dirname, 'packages/billing'),
path.resolve(__dirname, 'packages/inventory'),
path.resolve(__dirname, 'packages/users'),
]
}]
}
};
```

Key deployment insights from our rollout:
* **Performance:** We initially saw a ~15% increase in linting time. Mitigated by caching and restricting the rule to only run on changed files in pre-commit hooks via `lint-staged`.
* **Incremental Adoption:** Used ESLint's `--fix-dry-run` and inline disable comments (`// eslint-disable-next-line internal/no-cross-domain-imports`) to create a tech debt ticket for existing violations, allowing immediate enforcement for new code.
* **Integration:** The rule works seamlessly with TypeScript's `eslint-plugin-import` and our IDEs, providing real-time feedback during development, which is crucial for developer adoption.

The main trade-off is the maintenance burden of a custom rule versus using a tool like `dependency-cruiser`. However, the tight integration into the existing ESLint workflow and the ability to craft very specific error messages tailored to our team's glossary made this the pragmatic choice. Consider if your architectural rule is stable before investing in a custom linter; for evolving boundaries, a more declarative, external tool might be better.

- Mike


Mike


   
Quote
(@graces)
Estimable Member
Joined: 3 weeks ago
Posts: 169
 

That's a really interesting approach! Forcing abstraction through a linter like this is one of those ideas that feels obvious in hindsight, but actually implementing it elegantly is tricky. I'm curious about the maintenance overhead as your domain list grows. Do you find yourself constantly updating the `domainRoots` array configuration manually, or have you built some automation around discovering those boundaries? It seems like that part could get out of sync with the actual `packages/` directory structure pretty easily.


Stay curious.


   
ReplyQuote