Module Boundaries — eslint-plugin-boundaries, dependency-cruiser
TL;DR
A “rule” is only a rule if it’s enforced. Convention docs telling devs “don’t cross-import between features” get violated quietly until the codebase is a graph soup. Two tools enforce import boundaries: eslint-plugin-boundaries declares allowed dependencies per architectural layer and lints violations at edit time; dependency-cruiser validates the full import graph in CI with richer rules (cycles, orphans, depth). The senior reflex: pick one, encode the layout’s rules, gate on CI.
Interview Q&A
Q: What problems do these tools solve?
A: Without boundary enforcement, large codebases inevitably get:
- Cross-feature imports — feature A reaches into feature B’s internals, coupling them.
- Circular dependencies — A → B → A. Bundlers handle most, but they signal poorly-factored code.
- Layering violations — domain code imports UI code; presentation imports infrastructure.
- Orphaned files — code no one imports anywhere, dead but undeleted.
Code review should catch these — and misses them. Lint + CI catch them every time.
Q: eslint-plugin-boundaries — basic setup.
A:
// eslint.config.js
import boundaries from "eslint-plugin-boundaries";
export default [
{
plugins: { boundaries },
settings: {
"boundaries/elements": [
{ type: "shared", pattern: "src/shared/**/*" },
{ type: "feature", pattern: "src/features/*", mode: "folder" },
{ type: "app", pattern: "src/app/**/*" },
],
},
rules: {
"boundaries/element-types": ["error", {
default: "disallow",
rules: [
{ from: "shared", allow: ["shared"] },
{ from: "feature", allow: ["shared", "feature"] }, // can import shared + other feature's public API
{ from: "app", allow: ["shared", "feature"] },
],
}],
"boundaries/no-private": "error", // can't import internals across feature boundaries
},
},
];
Effect:
src/shared/Xcan only import othershared.src/features/auth/Xcan importsharedandfeatures/payments/index.ts(its public API), but notfeatures/payments/internals/....src/app/Xcan import shared + feature public APIs.
Violations show as ESLint errors in the editor + CI.
Q: boundaries/no-private — what does it do?
A: Treats the first file at the boundary (typically index.ts) as the public API; everything else is private. Cross-boundary imports must go through the index.
// Allowed
import { LoginForm } from "@/features/auth"; // through index.ts
// Forbidden
import { LoginForm } from "@/features/auth/components/LoginForm"; // bypasses the API
This is the lock that makes 01_feature_vs_layer.md’s “feature public API” pattern actually hold over time.
Q: dependency-cruiser — when over ESLint?
A: dependency-cruiser is a separate CLI tool that walks the full dep graph; richer rules:
- Circular deps (
no-circular). - Orphan modules (no one imports them).
- Max depth (no deeply-nested module chains).
- Cross-package rules in monorepos.
- Layer violations with regex patterns.
Run in CI; produces a visual graph (depcruise --output-type dot | dot ...) for review.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: "no-circular",
severity: "error",
from: {},
to: { circular: true },
},
{
name: "no-cross-feature",
severity: "error",
from: { path: "^src/features/([^/]+)/" },
to: {
path: "^src/features/([^/]+)/",
pathNot: "^src/features/$1/", // backreference — same feature is OK
},
},
{
name: "shared-cant-depend-on-features",
severity: "error",
from: { path: "^src/shared/" },
to: { path: "^src/features/" },
},
],
options: {
tsConfig: { fileName: "tsconfig.json" },
enhancedResolveOptions: { exportsFields: ["exports"], conditionNames: ["import", "require"] },
},
};
ESLint catches at edit time (per-file); dependency-cruiser sees the whole graph (catches transitive cycles ESLint can miss).
Use both:
- ESLint for fast-feedback during editing.
dependency-cruiserin CI for graph-wide invariants.
Q: Common rules to encode.
A:
- No cross-feature imports. Force features to depend only on
shared/or other features’ public APIs. - No circular dependencies. Period. Real bugs.
- No
shared/depending onfeatures/orapp/. Inversion of the dep direction. - No
features/depending onapp/. Same — features should be portable. - No deep imports across packages in a monorepo. Force consumers through
package/index.ts. - No imports from
__tests__/outside test files. Test code shouldn’t leak. - No imports from
node_modulespaths directly — use the package name.
Each is one line in the config. Each prevents a class of architectural rot.
Q: How do you handle exceptions?
A: ESLint inline disable for a specific case + comment explaining why:
// eslint-disable-next-line boundaries/element-types -- temporary: migrating auth state into a feature
import { internalHelper } from "@/features/payments/internal/helper";
If exceptions accumulate, the rule is wrong (or the architecture). Either tighten the rule or fix the violations.
Q: How does this interact with TS path aliases?
A: eslint-plugin-boundaries and dependency-cruiser both read tsconfig.json paths. As long as your imports resolve via TS, the lint sees the resolved path.
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@my/ui": ["./packages/ui/src"]
}
}
}
Both tools see @/features/auth/... as src/features/auth/... and apply rules accordingly.
Q: Monorepo cross-package imports.
A: Restrict to package/index.ts (no deep imports across packages):
// dependency-cruiser config
{
name: "no-cross-package-deep-imports",
severity: "error",
from: { path: "^packages/([^/]+)/" },
to: {
path: "^packages/([^/]+)/(?!index)",
pathNot: "^packages/$1/", // same package internal is fine
},
},
Or, more cleanly, set up exports in each package/package.json so only the public surface is importable — see ../09_build_tools/03_module_formats.md.
Q: Visualizing the dep graph.
A: dependency-cruiser outputs DOT, which Graphviz renders:
npx depcruise --include-only "^src" --output-type dot src | dot -T svg > graph.svg
Useful in PR review: “this PR adds 12 new cross-feature edges — let’s discuss.” Or scheduled audits to catch graph creep.
Q: When is enforcement too much?
A:
- Tiny apps — overhead exceeds benefit.
- Prototypes / exploration — friction slows down learning what the architecture should be.
- One-person projects — convention in your head is enforcement enough.
Add boundary enforcement when:
- Team > 3 people.
- Feature count > 5.
- You’ve already seen a cross-feature import bug.
Gotchas / edge cases
eslint-plugin-boundariesrequires patterns + types defined precisely — typos in patterns lead to “everything allowed” silently.tsconfigpaths — both tools follow them; don’t forget to update both when paths change.- Type-only imports (
import type) are often allowed across boundaries — types vanish at runtime, no coupling. Configure if you want them treated differently. - Generated code (codegen, OpenAPI clients) often has imports that violate rules. Exempt the generated directory.
- CI failures are non-obvious for
dependency-cruiser— runs after build, output is dense. Surface the violation count + a link to the graph in the failure message. - Editor support — ESLint plugins work in any editor;
dependency-cruiseris CI/CLI-only. ESLint gives faster feedback for daily work.
What a senior is expected to say
- “Encode architecture rules; don’t rely on convention.
eslint-plugin-boundariesfor edit-time feedback;dependency-cruiserfor graph-wide invariants in CI.” - “Cross-feature imports forbidden by default; features import from shared or from other features’ public APIs only. Enforce ‘no-private’ so people can’t bypass the index.ts.”
- “Circular dependencies are always errors — they signal poor factoring, even when bundlers paper over them.”
- “Inversion of layering (shared depending on features) gets flagged. The dep direction is
shared←features←app, never the reverse.” - “Visualize the dep graph in PR review when architecture is shifting. Catches creep that line-level review misses.”
Cross-references
- Feature architecture (the rules to enforce): 01_feature_vs_layer.md
- Barrels (boundary mechanism): 03_barrels.md
- Module formats +
exportsfield: ../09_build_tools/03_module_formats.md - Linting setup: 06_linting_and_formatting.md
Further reading
eslint-plugin-boundaries: https://github.com/javierbrea/eslint-plugin-boundariesdependency-cruiser: https://github.com/sverweij/dependency-cruiser- Madge (smaller cycle-detection tool): https://github.com/pahen/madge