frontend / project structure / 05_module_boundaries.md

Module Boundaries — eslint-plugin-boundaries, dependency-cruiser

6 min read source

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/X can only import other shared.
  • src/features/auth/X can import shared and features/payments/index.ts (its public API), but not features/payments/internals/....
  • src/app/X can 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-cruiser in CI for graph-wide invariants.

Q: Common rules to encode.

A:

  1. No cross-feature imports. Force features to depend only on shared/ or other features’ public APIs.
  2. No circular dependencies. Period. Real bugs.
  3. No shared/ depending on features/ or app/. Inversion of the dep direction.
  4. No features/ depending on app/. Same — features should be portable.
  5. No deep imports across packages in a monorepo. Force consumers through package/index.ts.
  6. No imports from __tests__/ outside test files. Test code shouldn’t leak.
  7. No imports from node_modules paths 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-boundaries requires patterns + types defined precisely — typos in patterns lead to “everything allowed” silently.
  • tsconfig paths — 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-cruiser is 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-boundaries for edit-time feedback; dependency-cruiser for 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 sharedfeaturesapp, never the reverse.”
  • “Visualize the dep graph in PR review when architecture is shifting. Catches creep that line-level review misses.”

Cross-references

Further reading