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?
export { flagAttempt } from './flags';export { scoreCache } from './internal/score-cache';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
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?
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);}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?
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?
import { rawEvents } from '../../watch-history/src/internal/raw-events';export const rank = () => rawEvents;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?
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];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?
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];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?
depConstraints: [ { sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] }, { sourceTag: 'scope:sales', onlyDependOnLibsWithTags: ['scope:sales', 'scope:shared'] }, { sourceTag: 'scope:inventory', onlyDependOnLibsWithTags: ['scope:inventory', 'scope:shared'] },]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?
Go deeper
Pro tips
- Write the boundary as an allow-list from the start: an
allowedsection withallowedSeverity: "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 newappealspackage 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$1inpathandpathNotrules." (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 severityerrorfound" (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 withallowedSeverity" (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. "--resolvePackageJsonExportsforces TypeScript to consult theexportsfield ofpackage.jsonfiles if it ever reads from a package innode_modules." and "This option defaults totrueunder thenode16,nodenext, andbundleroptions 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 withresolvePackageJsonExports: false. Valid as of 2026-10-15.
Related lessons
Checked against the tools and docs as of 2026-10-15.