Architecture-as-Code — JavaScript Implementation
Prerequisite. Read
architecture-as-codefirst. The schema (§1), subsystems (§2), forbidden edges (§3), rule placement (§4), assembler concept (§5), and anti-patterns / audit (§6) are defined there and apply identically here. This file documents only what is JavaScript-specific.
1. File format
- Filename:
eslint.architecture.mjs. Use.mjsonly;.jstrips source-discovery walkers and ESLint's own config-loader. - ES module with
export default { subsystems: [...], forbidden: [...] }, plus an optionalexternals: [...]for npm-package policy (§3).forbiddenrelates declared subsystems to each other and cannot name a package.componentsis read as a deprecated alias forsubsystems(§2). - Pattern syntax: filesystem globs (
<dir>/**). - Repo-root
package.jsonmust include"type": "module".
js// eslint.architecture.mjs — example for a subsystem with internal layering export default { subsystems: [ { name: 'core-facade', pattern: 'packages/core/index.js', mode: 'file' }, { name: 'core-tier1', pattern: 'packages/core/tier1/**' }, { name: 'core-tier3', pattern: 'packages/core/tier3/**' }, { name: 'core-other', pattern: 'packages/core/**' }, // catch-all, last ], forbidden: [ // Efferent — self-knowledge, lives in own file. { from: 'core-*', to: '*', except_to: ['core-*'], why: 'Core purity: no imports outside the core directory.' }, // Internal layering — own-prefix only. { from: 'core-tier3', to: 'core-tier1', why: 'Tier 3 must go through tier 2.' }, ], };
Boundaries-plugin's parametric "not equal" syntax for cross-domain isolation
is !{{from.captured.domain}} (double braces).
2. Assembler
eslint.config.js discovers every eslint.architecture.mjs deeper-first at
lint startup, merges and expands them, and emits one
eslint-plugin-boundaries block whose files glob equals the linted source
set, with the file-existence and dependency rules both at error. Read
references/assembler.md before creating or
changing eslint.config.js, the assembler, or that block's files,
severity, or ignores: it holds the code and why each line is shaped the
way it is.
3. JavaScript-specific enforcement recipes
Confine a provider SDK to its adapter
The architecture-guidelines §10 handoff usually arrives as "only the adapter
may talk to the provider". A provider SDK is an npm package, not an
element, so forbidden cannot express it: that list only relates declared
subsystems to each other. Declare package policy separately.
js// eslint.architecture.mjs export default { subsystems: [ { name: 'email-adapter', pattern: 'api/src/email/**' }, { name: 'api-other', pattern: 'api/src/**' }, // catch-all, last ], forbidden: [ /* element-to-element edges */ ], externals: [ { package: '@azure/communication-email', allow: ['email-adapter'], why: 'The provider SDK is confined to its adapter; callers use the port.' }, ], };
Assembler translation. Policies are evaluated in order and the last match wins, so deny the package everywhere first, then re-allow its owning elements:
jsconst allExternals = archs.flatMap(m => m.default.externals ?? []); const externalRules = allExternals.flatMap(x => [ { from: expand('*'), disallow: { to: { module: { origin: 'external', source: x.package } } }, message: x.why }, { from: expand(x.allow), allow: { to: { module: { origin: 'external', source: x.package } } } }, ]); 'boundaries/dependencies': ['error', { default: 'allow', checkAllOrigins: true, // REQUIRED — see below rules: [...rules, ...externalRules], }],
Two traps make this silently wrong:
- Without
checkAllOrigins: truethe policy never fires. The rule reports nothing, the lint is green, and the boundary does not exist. The flag is not an optimization. checkAllOrigins: truesubjects every package to the block'sdefault. Withdefault: 'disallow'that turns one SDK policy into a repo-wide import ban. Keep the blanketdefault: 'allow'and express prohibition as rules, or add an explicit allow-all policy first.
Register the rule at error. At warn this is a report, not a boundary.
Prove it red first. Add a throwaway file that imports the package from
outside the adapter, confirm the lint fails on it with the declared message and
line, delete the file, then confirm the adapter and its callers pass. A policy
that has never failed has not been shown to run — the checkAllOrigins trap
above produces exactly the same green output as a working rule.
Require literal dynamic-import paths
A computed import(expression) can bypass path resolution and therefore every
subsystem boundary. Apply this rule to every production and test source block:
js{ files: ['**/*.{js,jsx,mjs,ts,tsx}'], rules: { 'no-restricted-syntax': [ 'error', { selector: 'ImportExpression[source.type!="Literal"]', message: 'Dynamic import paths must be string literals so dependency boundaries remain enforceable.', }, ], }, }
This permits import('./known-module.js') and rejects variables, concatenation,
and template expressions. Keep the rule in the same flat config as the
boundaries rules so a new source block cannot silently omit it.
Prevent production imports of test-only code
Declare test code as a narrower subsystem before the production catch-all, then forbid the production subsystem from importing it:
jsexport default { subsystems: [ { name: 'app-test', pattern: 'src/**/*.test.{js,jsx,ts,tsx}' }, { name: 'app-test-support', pattern: 'test/**' }, { name: 'app-prod', pattern: 'src/**' }, // catch-all, last ], forbidden: [ { from: 'app-prod', to: 'app-test*', why: 'Production code must not import test-only code.', }, ], };
Tests may still import production subsystems. Adapt the globs to the repository, but retain the direction: production → test is forbidden.
Classify repository-root files
Root-level loose files — configs, entry scripts, generators — are the ones most
often left unclassified, and the pattern's catch-all directive does not help
here. A folder-mode ** at the root matches at the shallowest segment and
claims files that specific elements already own, from any position in the list
(see § 5). Declare the root's files with mode: 'file' instead:
js// eslint.architecture.mjs (repository root) export default { subsystems: [ { name: 'repo-tooling-config', pattern: '*.config.js', mode: 'file' }, { name: 'repo-tooling-entry', pattern: 'server.js', mode: 'file' }, // No '**' catch-all at this level. ], };
boundaries/no-unknown-files then names each root file still unclassified, so
the list comes from the linter's output rather than from memory.
4. Output Contract
When applying this implementation, emit:
Scope: <repo / package / subsystem path> Decision: Add eslint.architecture.mjs | Update assembler | Update ESLint config | Blocked Generated config:<path, if any> Rules changed: <boundaries/dependencies element edges, externals package policy, or no-restricted-imports entries> Verification: <eslint command / assembler command / red-first proof / Not run + reason> Next action: <specific file edit, dependency install, or unresolved question>
5. JavaScript-specific gotchas
[!NOTE] Unresolved imports bypass enforcement.
eslint-plugin-boundariesonly enforces rules on imports it can resolve to a file path. Host-served absolute paths (e.g. SWA's/js/...) aren't resolved by default and pass silently. Fix: installeslint-import-resolver-aliasand add it undersettings['import/resolver']so/js → packages/.../jsresolves.
[!IMPORTANT] The rule block's
filesglob is a second, independent gate. A correct element registry, correct forbidden edges, and strict severities do nothing on a path outsidefiles. A narrowed glob —['packages/**/*.{js,ts,mjs}'], or an allowlist that grew one directory at a time — lets an entire new top-level directory into the repository with the boundaries lint reporting zero violations, because the rule never ran on it. Nothing in the output distinguishes that from a clean run. Keep the scope at['**/*.{js,jsx,mjs,ts,tsx}'].
[!IMPORTANT]
modedecides what a pattern matches, and the default isfolder. In folder mode a pattern is tested against a file's path ancestors, so**matches at the shallowest segment and captures files that deeper, more specific elements already own — regardless of its position in the list. A root-level{ name: 'repo-unclassified', pattern: '**' }declared last, after several dozen specific elements, still wins, and yields a flood of misclassifications rather than the intended safety net. Usemode: 'file'for anything a shallower pattern would swallow.
[!NOTE]
modeis deprecated in v7 and the suggested replacement does not cover this case. The deprecation warning recommendspartialMatch: false; that does not classify a repository-root file — with a glob or with an exact filename, the file stays unknown.mode: 'file'is currently the only form that works. Accept the warning instead of chasing it.
[!NOTE]
boundaries/externalstill works and is deprecated in v7. Unlike themodedeprecation above, this one has a working replacement: fold package policy intoboundaries/dependencieswithcheckAllOrigins: trueand theto.module.origin/to.module.sourcesub-selector (§3, Confine a provider SDK to its adapter). Do not run both rules over the same package — two verdicts on one edge, and the one that reports depends on rule order.
[!NOTE] Unmatched files bypass enforcement — silently. Files matching no element are invisible to
boundaries/dependencies. End every constrained subsystem'ssubsystemslist with a<dir>/**catch-all (pattern Directive 5), and setboundaries/no-unknown-filestoerrorso an unmatched file is reported instead of ignored. Dependency rules are not a substitute: a file with no imports, or one loaded by a<script>tag, has no edge to judge.
[!NOTE] The plugin forbids; it never requires. An aspect coverage check — every subsystem in the governed set uses the mechanism — has no
boundaries/dependenciesform.dependency-cruiser'srequiredrules can express it over the same registry;defect-shift-leftplaces that check.
[!IMPORTANT] Flat config replaces a rule's options per rule id. Two blocks whose
filesoverlap and that both setno-restricted-syntaxorno-restricted-importsleave only the later block's entries active, and lint still passes. Merge the entries in the assembler.
[!NOTE] Emitted options are copied for every linted file. One selector per category can grow the boundaries options to megabytes and most of the lint time. Group element names per rule, measure uncached lint time after an assembler change, and guard the emitted size with a test.
[!NOTE] Facade-as-file pattern. JavaScript idiomatically exposes a facade as a single index/entry file. Use
mode: 'file'plus an exact-pathpattern(no glob) so the facade is matched alone.
[!NOTE]
.jsconfig breaks discovery. Many source-discovery walkers (the assembler's own, plus some lint plugins) treat.jsas analyzable source. Naming the architecture file.jstriggers self-reference and mis-classification..mjsis required.


