Skip to content

Enforced module boundaries as the team grows

A boundary is a rule CI runs: one public entry per module, and a check that fails forbidden imports and loops.

Predict first

Before you read: predict

ExamHall's monorepo keeps each package under packages/<name>/. CI runs dependency-cruiser 18.5.0 over packages from the repository root and fails a merge request on any error.

.dependency-cruiser.json:

{ "forbidden": [
  { "name": "only-through-entry", "severity": "error", "from": { "path": "^packages/([^/]+)/" }, "to": { "path": "^packages/", "pathNot": ["^packages/$1/", "^packages/[^/]+/src/index\\.ts$"] } }
] }

The tree:

packages/contracts/src/index.ts
packages/grading/src/marker.ts
packages/grading/src/internal/weights.ts
packages/proctoring/src/index.ts
packages/proctoring/src/flags.ts
packages/proctoring/src/internal/score-cache.ts

packages/proctoring/src/index.ts:

A merge request gives Grading's marker four new imports. packages/grading/src/marker.ts:

import { examWindow } from '../../contracts/src/index'; // (1)
import { cachedScore } from '../../proctoring/src/internal/score-cache'; // (2)
import { weights } from './internal/weights'; // (3)
import { scoreCache } from '../../proctoring/src/index'; // (4)
export const marking = [examWindow, cachedScore, weights, scoreCache];

The files whose code is not shown import nothing.

Which of the four imports does the check report?

Code for this question
export { flagAttempt } from './flags';export { scoreCache } from './internal/score-cache';
Pick one

The 30-second version

A modular monolith keeps its modules apart only while a machine checks the imports. Give each module one public entry file and keep the rest internal. In CI, a rule over the import graph fails a merge request that reaches into another module's internals or closes a loop between modules. Nx expresses the same rules with tags.

A boundary is a rule CI runs: one public entry per module, and a check that fails forbidden imports and loops.

How it works

Step 1 of 14

A check makes the boundary. Unchecked boundaries erode (arch-monolith-vs-microservices step 3). Here a tool reads every import and fails a rule-breaking merge request.
In the kitchen
  • the unit boundaries painted on the plot plan stop nobody laying a temporary hose → a folder alone is no boundary (step 1)
  • every change to the piping drawings is checked against the route table before anyone welds → the check on every merge request (step 1)

Analogy: An oil refinery's process units, the tank farm and the route table

At the refinery In the repository
each unit sends product to another only through its rundown line into the tank farm, never by a hose tapped into another unit's vessel one public entry per module (steps 2–3)
the unit boundaries painted on the plot plan stop nobody laying a temporary hose a folder alone is no boundary (step 1)
the control system opens a transfer valve only for a route listed in its route table a machine rule and an allow-list (steps 5, 10)
a route set to "alarm only" while it is commissioned sounds the alarm and lets the product flow warn against error (step 9)
the cracker cannot start without the hydrogen unit's hydrogen, and the hydrogen unit cannot start without the cracker's off-gas a loop between modules (step 8)
every change to the piping drawings is checked against the route table before anyone welds the check on every merge request (step 1)

In real terms: give every package one entry, and let CI refuse any import that skips an entry or closes a loop.

Breaks down when: a new pipe takes weeks to lay and the whole site sees it, while a new import is one line in a diff that nobody notices.

Contrasting case: a single still in a shed with one operator, where there is nothing to route.

Examples, run for real

4.1 ExamHall EXH-4320: the score cache Grading read directly

EXH-4320: Proctoring re-keyed its internal score cache by session instead of attempt, and Grading, which imported the cache file directly, marked a morning's attempts against empty scores. MR !4620 puts the check of steps 5–8 in CI with severity: "error", moves every cross-package import onto an entry, and breaks the loop between Grading and Results by moving the shared score-row builder into Contracts. The same check ran on the fixture tree before and after:

4.1 EXH-4320 before: 11 files · only-through-entry 3 errors · no-cycle 1 error · depcruise exit 4
4.1 MR !4620 after: 11 files · only-through-entry 0 · no-cycle 0 · depcruise exit 0

Before, three imports skipped an entry and one loop ran through two packages' entries; the job would have failed with exit code 4 (step 9). After, both rules pass, so the next import that skips an entry fails its own merge request.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A video platform's monorepo runs dependency-cruiser 18.5.0 over packages in CI from the repository root. Its configuration has no options section, and this one rule.

.dependency-cruiser.json:

{ "forbidden": [
  { "name": "only-through-entry", "severity": "error", "from": { "path": "^packages/([^/]+)/" }, "to": { "path": "^packages/", "pathNot": ["^packages/$1/", "^packages/[^/]+/src/index\\.ts$"] } }
] }

The tree:

packages/drm/src/index.ts
packages/drm/src/internal/licence.ts
packages/drm/src/internal/renew.ts
packages/drm/src/internal/keys.ts
packages/player/src/session.ts

packages/player/src/session.ts:

The files whose code is not shown import nothing.

Which of the three imports does the check report?

Code for this question
import type { Licence } from '../../drm/src/internal/licence'; // (1)import { renewLicence } from '../../drm/src/internal/renew'; // (2)export async function startSession(viewerId: string) {  const licence: Licence = await renewLicence(viewerId);  const { keyFor } = await import('../../drm/src/internal/keys'); // (3)  return keyFor(licence);}
Pick one

Question 2 of 7

The video platform's CI runs dependency-cruiser 18.5.0 over packages from the repository root with two rules.

.dependency-cruiser.json:

{ "forbidden": [
  { "name": "only-through-entry", "severity": "error", "from": { "path": "^packages/([^/]+)/" }, "to": { "path": "^packages/", "pathNot": ["^packages/$1/", "^packages/[^/]+/src/index\\.ts$"] } },
  { "name": "no-cycle", "severity": "error", "from": {}, "to": { "circular": true } }
] }

Every package has only its entry, packages/<name>/src/index.ts, and each entry imports the entries of the packages listed beside it:

catalogue     -> playback
playback      -> drm
drm           -> entitlements
entitlements  -> (none)
player-ui     -> playback
search        -> catalogue

A merge request makes entitlements' entry import catalogue's entry.

Which packages does the check then name in a cycle?

Pick one

Question 3 of 7

In the video platform's monorepo, packages/recommendations/src/rank.ts imports ../../watch-history/src/internal/raw-events. CI runs depcruise --config .dependency-cruiser.json packages from the repository root and fails the job when that command exits non-zero. Four engineers each propose a .dependency-cruiser.json so that the job fails on that import.

The tree:

packages/recommendations/src/rank.ts
packages/watch-history/src/index.ts
packages/watch-history/src/internal/raw-events.ts

packages/recommendations/src/rank.ts:

Ravi's .dependency-cruiser.json:

{ "forbidden": [ { "name": "only-through-entry", "severity": "warn", "from": { "path": "^packages/([^/]+)/" }, "to": { "path": "^packages/", "pathNot": ["^packages/$1/", "^packages/[^/]+/src/index\\.ts$"] } } ] }

Ana's .dependency-cruiser.json:

{ "forbidden": [ { "name": "only-through-entry", "severity": "error", "from": { "path": "^packages/([^/]+)/" }, "to": { "path": "^packages/", "pathNot": ["^packages/[^/]+/src/index\\.ts$"] } } ] }

Mei's .dependency-cruiser.json:

{ "allowed": [ { "from": { "path": "^packages/([^/]+)/" }, "to": { "path": "^packages/$1/" } }, { "from": { "path": "^packages/" }, "to": { "path": "^packages/[^/]+/src/index\\.ts$" } } ] }

Tom's .dependency-cruiser.json:

{ "forbidden": [ { "name": "only-through-entry", "severity": "error", "from": { "path": "^([^/]+)/" }, "to": { "path": "^[^/]+/", "pathNot": ["^$1/", "^[^/]+/src/index\\.ts$"] } } ] }

The files whose code is not shown import nothing.

Whose configuration makes the job fail?

Code for this question
import { rawEvents } from '../../watch-history/src/internal/raw-events';export const rank = () => rawEvents;
Pick one

Question 4 of 7

The video platform's monorepo is an npm workspace: each package is linked into node_modules under its name. packages/drm/package.json:

{ "name": "@vidco/drm", "main": "./src/index.ts", "exports": { ".": "./src/index.ts" } }

At the repository root, tsconfig.json:

{ "compilerOptions": { "strict": true, "target": "es2022", "module": "esnext", "moduleResolution": "bundler", "allowImportingTsExtensions": true, "noEmit": true }, "include": ["packages"] }

The tree:

packages/drm/package.json
packages/drm/src/index.ts
packages/drm/src/internal/rotate.ts
packages/drm/src/internal/key-store.ts
packages/player/src/keys.ts

packages/player/src/keys.ts:

The files whose code is not shown import nothing, and each exports the names imported from it.

Which imports does TypeScript 6.0.3 (tsc) reject?

Code for this question
import { licenceFor } from '@vidco/drm'; // (1)import { rotateKeys } from '@vidco/drm/src/internal/rotate'; // (2)import { keyStore } from '../../drm/src/internal/key-store'; // (3)export const keyTools = [licenceFor, rotateKeys, keyStore];
Pick one

Question 5 of 7

The video platform's CI runs dependency-cruiser 18.5.0 over packages from the repository root with an allow-list.

.dependency-cruiser.json:

{ "allowed": [
  { "from": { "path": "^packages/([^/]+)/" }, "to": { "path": "^packages/$1/" } },
  { "from": { "path": "^packages/" }, "to": { "path": "^packages/contracts/src/index\\.ts$" } },
  { "from": { "path": "^packages/player/" }, "to": { "path": "^packages/(drm|catalogue)/src/index\\.ts$" } }
], "allowedSeverity": "error" }

The tree:

packages/contracts/src/index.ts
packages/catalogue/src/index.ts
packages/player/src/index.ts
packages/subtitles/src/index.ts
packages/subtitles/src/internal/cues.ts

A merge request adds the subtitles package. packages/subtitles/src/index.ts:

The files whose code is not shown import nothing.

How many of the four imports does the check report?

Code for this question
import { localeOf } from '../../contracts/src/index'; // (1)import { cueTimes } from './internal/cues'; // (2)import { titleOf } from '../../catalogue/src/index'; // (3)import { segmentAt } from '../../player/src/index'; // (4)export const subtitles = [localeOf, cueTimes, titleOf, segmentAt];
Pick one

Question 6 of 7

A ticketing company's Nx workspace lints with @nx/enforce-module-boundaries, configured with:

Its projects, their tags and the projects each imports:

contracts  [scope:shared]     -> (none)
seat-map   [scope:inventory]  -> contracts
pricing    [scope:sales]      -> contracts
checkout   [scope:sales]      -> pricing, seat-map
waitlist   []                 -> contracts, seat-map

waitlist was added last week and has no tags yet.

How many of these six imports does the rule report?

Code for this question
depConstraints: [  { sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },  { sourceTag: 'scope:sales', onlyDependOnLibsWithTags: ['scope:sales', 'scope:shared'] },  { sourceTag: 'scope:inventory', onlyDependOnLibsWithTags: ['scope:inventory', 'scope:shared'] },]
Pick one

Question 7 of 7

A payments-ledger monorepo has packages api, reports, statements, postings, fx-rates and money, each with its entry at src/index.ts. Its imports between packages, file to file:

packages/api/src/routes.ts         -> packages/postings/src/index.ts
packages/api/src/routes.ts         -> packages/statements/src/index.ts
packages/reports/src/monthly.ts    -> packages/statements/src/index.ts
packages/reports/src/monthly.ts    -> packages/fx-rates/src/internal/table.ts
packages/statements/src/render.ts  -> packages/postings/src/internal/journal.ts
packages/fx-rates/src/convert.ts   -> packages/money/src/internal/round.ts

In an interview you propose moving one of postings, statements or fx-rates into its own service, and the interviewer asks you to choose.

Which one can move without first editing a file in any other package?

Pick one

Go deeper

Pro tips
  1. Write the boundary as an allow-list from the start: an allowed section with allowedSeverity: "error", so a new package or a new import between packages fails until someone lists it. When: setting up the check, before a second team joins (steps 9–10). Verify (RAN, tips.mjs): MR !4620's tree with a new appeals package that imports Grading's entry, checked first with MR !4620's forbidden rules, then with an allow-list of the pairs the tree already uses.
tip 1 a new package appeals imports grading's entry: MR !4620's forbidden rules exit 0 · an allow-list of the 5 package pairs the tree uses exit 1 (1 not-in-allowed: packages/appeals/src/index.ts -> packages/grading/src/index.ts)
Source
  • Nx docs, "Enforce Module Boundaries" (the row's first source): https://nx.dev/docs/features/enforce-module-boundaries · fetched 2026-10-15 with curl. "If all of them can depend on each other freely, chaos will ensue, and the workspace will become unmanageable." (step 1); "You can declaratively define constraints using project tags and enforce them automatically." and "With these constraints in place, scope:client projects can only depend on projects with scope:client or scope:shared." (step 12); "Projects without any tags cannot depend on any other projects." (step 12). Valid as of 2026-10-15.
  • dependency-cruiser, rules reference, options reference, command line and README (the row's second source), at tag v18.5.0, the version the scripts run: https://github.com/sverweij/dependency-cruiser (doc/rules-reference.md, doc/options-reference.md, doc/cli.md, README.md) · fetched 2026-10-15 (sources/depcruise-*.txt). "A list of rules that describe dependencies that are not allowed." (step 5); "You can reference the part matched between brackets by using $1 in path and pathNot rules." (step 6); "By default dependency-cruiser does not take dependencies between typescript modules into account that don't exist after compilation to JavaScript." (step 7); "A Boolean indicating whether or not to match module dependencies that end up where you started (a.k.a. circular dependencies)." (step 8); "it will exit with exit code number of violations with severity error found" (step 9); "dependency-cruiser will emit a 'not-in-allowed' message for each dependency that does not satisfy at least one of them." (step 10); "The severity of the message is warn by default, but you can override it with allowedSeverity" (step 10). Valid as of 2026-10-15 for 18.5.0.
  • Node.js docs, "Modules: Packages" (v24.x): https://raw.githubusercontent.com/nodejs/node/v24.x/doc/api/packages.md · fetched 2026-10-15. "The "exports" provides a modern alternative to "main" allowing multiple entry points to be defined, conditional entry resolution support between environments, and preventing any other entry points besides those defined in "exports"." (step 11). Not a row source: a runtime standard's documentation, cited for teaching only. Valid as of 2026-10-15.
  • TypeScript docs, TSConfig reference, "Resolve package.json Exports": https://raw.githubusercontent.com/microsoft/TypeScript-Website/v2/packages/tsconfig-reference/copy/en/options/resolvePackageJsonExports.md · fetched 2026-10-15 with curl. "--resolvePackageJsonExports forces TypeScript to consult the exports field of package.json files if it ever reads from a package in node_modules." and "This option defaults to true under the node16, nodenext, and bundler options for --moduleResolution." (step 11). Not a row source, and the page is not versioned: P4's key comes from running the TypeScript 6.0.3 compiler, and C's world is that run with resolvePackageJsonExports: false. Valid as of 2026-10-15.

Checked against the tools and docs as of 2026-10-15.