Enforcing Architecture Rules with ESLint
How to encode architectural decisions into ESLint rules that run on every commit — so the architecture enforces itself.
Architectural decisions are easy to document and easy to ignore. You write the ADR, you put it in the wiki, and six months later a new engineer imports directly from an internal implementation file because the type definition they needed was right there.
The way to make architectural decisions stick is to make violations into errors — errors that fail CI and fail pre-commit hooks. Not code review comments. Errors.
ESLint is the right place to do this because it runs on every save, every commit, and every CI run. If you can express the rule as a lint rule, you can enforce it everywhere.
The Problem with Unconstrained Imports
In a monorepo, the implicit rule is often: packages can import from each other freely. In practice this means:
apps/dashboardimports directly frompackages/ui/src/internal/tokens.tsinstead of the public APIpackages/utilsimports fromapps/dashboard(a circular dependency)packages/analyticsimports frompackages/uiwhen it shouldn't know about UI at all
These violations are hard to spot in code review. They're easy to catch with a rule.
eslint-plugin-boundaries
This is the plugin I reach for to enforce module boundaries:
pnpm add -D eslint-plugin-boundariesThe core concept is element types — you categorize your codebase into zones, and then declare which zones can import from which:
// eslint.config.js
import boundaries from 'eslint-plugin-boundaries';
export default [
{
plugins: { boundaries },
settings: {
'boundaries/elements': [
{ type: 'app', pattern: 'apps/*' },
{ type: 'package', pattern: 'packages/*' },
{ type: 'shared', pattern: 'packages/shared' },
],
},
rules: {
'boundaries/element-types': ['error', {
default: 'disallow',
rules: [
// apps can import from packages
{ from: 'app', allow: ['package', 'shared'] },
// packages can only import from shared
{ from: 'package', allow: ['shared'] },
// shared can't import from anything
{ from: 'shared', allow: [] },
],
}],
},
},
];Now packages/analytics importing from packages/ui is a lint error. Not a PR comment — a lint error.
no-restricted-imports
For finer-grained control without a plugin, ESLint's built-in no-restricted-imports rule lets you ban specific import paths:
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['*/internal/*', '**/internal/**'],
message: 'Do not import from internal directories. Use the public API instead.',
},
{
group: ['lodash', 'lodash/*'],
message: 'Import from lodash-es for tree-shaking: import { debounce } from lodash-es',
},
],
}],
}This pattern is useful when you want to enforce conventions at a path level rather than a module zone level. "Never import from */internal/*" is a rule that's easy to understand and easy to enforce.
Enforcing Design System Usage
One of the highest-value rules in a design system monorepo: prevent teams from styling raw HTML elements when a component already exists.
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
// Discourage importing React directly for commonly wrapped hooks
group: ['react'],
importNames: ['useState'],
message: 'Prefer useLocalState from @myapp/state which adds debugging support.',
},
],
}],
}Or, the inverse — require that certain things come from specific packages:
// Custom rule or eslint-plugin-import
'import/no-extraneous-dependencies': ['error', {
devDependencies: ['**/*.test.ts', '**/*.spec.ts', '**/vite.config.ts'],
}]The Linting Architecture Strategy
The strategy I've landed on for large codebases:
-
Start with
boundaries/element-types— categorize your packages into zones and define the allowed dependency graph. This catches the big structural violations. -
Add
no-restricted-importsfor specific patterns — individual library substitutions, internal path restrictions, deprecated API bans. -
Write custom rules for project-specific constraints — things that are specific to your design system or codebase that no existing plugin covers. (More on that in the next post.)
-
Wire it into pre-commit hooks — the rule only works if it runs. Husky + lint-staged means it runs on every commit, not just CI.
The philosophy here is the same one that applies to TypeScript strictness: the goal isn't to write more config. It's to encode decisions in a system that enforces them so you don't have to enforce them manually in code review.
The Long Game
I've found that architectural rules in ESLint do something beyond just catching violations: they document intent. When a new engineer sees a lint error that says "Do not import from */internal/* — use the public API", that's a self-explanatory explanation of a decision the team made.
A comment in a README that nobody reads doesn't do that. A lint error does.
The rules become the living documentation of how the codebase is supposed to be structured — and unlike a wiki page, they can't go stale without breaking the build.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.