ADR-0001: Library taxonomy and enforced dependency direction
- Status: Accepted (2026-09-13)
- Relates to: ADR-0002 (the barrel is the public interface), ADR-0004 (cross-feature access), ADR-0005 (what
shared/means), ADR-0006 (the e2e suite is its own project)
Context
The repository is an Nx monorepo with 28 projects. The folder names (core/, ui/, shared/, pages/, resource-editor/) suggest a layered architecture. Nothing enforces it.
The decision was started and then abandoned. All twenty-seven library project.json files carry a "tags" key. Twenty-three of them are []. Four carry real tags using the canonical Nx type:/scope: convention: libs/dsp-js (scope:shared, type:data-access), libs/vre/ui/ui, libs/vre/ui/nested-menu and libs/vre/ui/string-literal (all type:ui). The twenty-eighth project, apps/dsp-app, has no tags key at all. The rule in eslint.config.mjs:99 is configured with a single permissive constraint:
depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }]
This permits every dependency. Consequently the folder names carry no weight and layering has drifted. Measured against main at 238000ec2, counting runtime code only (*.spec.ts, *.stories.ts and stories.helpers.ts excluded, since decision 5 exempts them):
- The eight libraries under
libs/vre/pages/form eleven dependency edges among themselves, covering seventeen distinct symbols. Seven of the eight are routed pages;pages/data-browseris a component library thatpages/projectroutes to. The two most-imported symbols areProjectPageService(three other page libraries, sixteen files) andAllProjectsService(two, three files). core/error-handlerdepends onui/notification.ui/*reaches intoshared/app-common,shared/app-helper-servicesand3rd-party-services/open-apiin four files, and intodsp-jsin seven more.
The composition root that makes page-to-page imports unnecessary already exists and is already used: apps/dsp-app/src/app/app.routes.ts imports all seven routed page libraries directly (the eighth library under pages/, data-browser, is reached through DataBrowserPageComponent in pages/project) and contains zero loadComponent and zero loadChildren calls. Pages are already assembled by the application. They simply also import each other, which is the part that has no justification.
Decision
- Every project carries one
type:tag and onescope:tag. (structure)
Types: app, feature, ui, data-access, util, e2e. This is the Nx taxonomy and it is adopted unchanged so that external documentation applies without translation.
e2e has nothing to apply it to at the time of writing. The Cypress suite is not an Nx project: it lives inside the application at apps/dsp-app/cypress/ as a directory plus an e2e target on apps/dsp-app, and eslint.config.mjs:173 excludes that directory from linting entirely, boundary rule included. ADR-0006 creates dsp-app-e2e as a project and gives e2e something to tag, along with the constraint in decision 2. Until that lands, the tag is declared and unused.
Scopes: shared for anything reusable across domains, one scope per page domain (project, ontology, search, user, system), and app for the application itself.
- The type constraint fixes the tier. (static-analysis)
type:app -> type:feature, type:ui, type:data-access, type:util
type:feature -> type:feature, type:ui, type:data-access, type:util
type:ui -> type:ui, type:util
type:data-access -> type:data-access, type:util
type:util -> type:util
type:e2e -> type:data-access, type:util
type:ui -> type:ui, type:util is the strict reading. It comes from the Nrwl "Enterprise Angular Monorepo Patterns" taxonomy. Nx's own more recent blog material sometimes shows ui -> data-access. The sources genuinely disagree. We take the strict reading deliberately; see Alternatives rejected.
- The scope constraint fixes the domain, and is what forbids feature-to-feature coupling. (static-analysis)
scope:shared -> scope:shared
scope:<domain> -> scope:<domain>, scope:shared
scope:app -> *
Nx evaluates every matching constraint, so a library tagged scope:project, type:feature must satisfy both blocks. A page library therefore cannot import another page library, because their scopes differ, even though the type rule would permit feature -> feature.
This placement is deliberate. Expressing the ban at scope level rather than type level means a feature that genuinely serves more than one domain has a legal home: it moves to scope:shared. resource-editor is already exactly that. It is imported by apps/dsp-app, by pages/data-browser and, through a dynamic import() in data-class-panel.component.ts, by pages/project, and it imports no page library at all. It becomes scope:shared, type:feature and all three edges stay legal. pages/data-browser is the second case: nothing routes to it directly and three libraries render it inside themselves (ADR-0004, decision 7), so it carries the same tags despite its folder.
- The tag is the truth. The folder is a hint. (review)
libs/vre/ui/notification contains one implementation file, app-notification.service.ts, a service wrapping MatSnackBar. It is type:util, not type:ui. Once tagged honestly, the core/error-handler -> ui/notification edge is legal and was never the real problem; the folder name was. Folders are renamed to match tags opportunistically, never urgently, because a rename touches every importer and buys nothing the tag does not already buy.
*.stories.tsis exempt from@nx/enforce-module-boundaries. (structure)
eslint.config.mjs already switches the rule off for **/environment*.ts (line 118) and **/*.spec.ts (line 125). It says nothing about stories, so stories are currently boundary-checked. Several stories import across tiers that the runtime code beneath them does not touch: ui/nested-menu stories reach core/config and shared/app-helper-services, and ui/string-literal stories reach core/session. Stories are test fixtures and receive the same exemption as specs. Without this, the constraint block below fails on libraries whose shipped code is clean.
- Rollout order is forced by the tool, not chosen by us. (structure)
The Nx rule states: "A project without tags matching at least one constraint cannot depend on any libraries." The moment one real constraint replaces the * constraint, every project matching none of the constraints loses the ability to depend on anything. The order is therefore:
a. Tag all twenty-eight projects and add the stories exemption, while { sourceTag: '*', onlyDependOnLibsWithTags: ['*'] } is still in place. No lint behaviour changes.
b. Clear the blocking imports listed in decision 7.
c. Replace the constraint block in a single commit.
Steps (a) and (c) must not be combined into one change.
- The known blocking imports, and the shape of each fix. (review)
The constraint block was simulated against the current runtime graph, with all twenty-eight projects tagged as in the table below, applying Nx's own evaluation rules: every constraint whose sourceTag appears in the source's tags must pass, and within one onlyDependOnLibsWithTags the target needs one matching tag. *.spec.ts and *.stories.ts were excluded as exempt.
The result is seventeen violating edges across thirty-nine distinct files, distributed very unevenly:
| Constraint | Violating edges | Distinct files |
|---|---|---|
type:data-access |
0 | 0 |
type:util |
0 | 0 |
type:feature, type:app |
0 | 0 |
type:ui |
4 | 6 |
scope:* |
13 | 33 |
The type tier is almost clean. Tagging dsp-js per decision 8 and ui/notification per decision 4 removes every violation in the data-access and util tiers, and type:feature is unconstrained enough to pass as-is. Essentially all the work is in the scope tier, which is the feature-to-feature coupling ADR-0004 addresses.
type:ui: six files, one design question and one named compromise.
AvailableLanguageandAvailableLanguageKeysare imported fromcore/configby five files inui/string-literal. They are a language list, not configuration. Extract them into atype:utillibrary. This is the whole of theui -> core/configdependency, and removing it letscore/configbe taggedtype:data-accesshonestly, since it also vendsKnoraApiConnectionthroughdsp-api-tokens.ts.joinPlaceholderLegalValuesfromshared/app-common, used byui/ui/src/lib/resource-rights-statement.component.ts:7. A pure function. Same util library.pickPreferredLanguageStringfromshared/app-helper-services, used byui/string-literal/src/lib/stringify-string-literal.pipe.ts:5. A pure function. Same util library.-
LanguageStringDtoandStringLiteralWithLanguagefrom3rd-party-services/open-api, used type-only by two files inui/string-literal. Declare the shape locally in the UI library. Share concepts, never shapes (ADR-0004, decision 6).This is a collapse, not an addition.
ui/string-literalcurrently holds four types for the single concept "a string in several languages":StringLiteralandStringLiteralV2fromdsp-js, andLanguageStringDtoandStringLiteralWithLanguagefrom the generated client.CONTEXT.mdflags the first pair under its own ambiguity and resolves it with "pick by which client you are on, not by preference". That guidance is right for code that is on a client. A presentational library is on neither, which is why the answer here is a local declaration instead, and why this ADR settles that flagged ambiguity for thetype:uitier specifically. One local type replaces four imported ones; it does not become a fifth. -LocalizationServicefromshared/app-helper-services, injected by two files inui/string-literal. This is the one genuineui -> data-accessdependency and the only one requiring a design decision. Two candidates: pass the active language in as a component input, or readLOCALE_IDfrom@angular/core, which is a framework token rather than a workspace dependency. Resolved in the implementing issue, not here. -dsp-jsvalue types inui/*(seven files, one import each:KnoraDate,KnoraPeriod,Precision,Constants,StringLiteral,StringLiteralV2,ListNodeV2WithAllLanguages). These are runtime values, not type-only imports, so they cannot be carved out with a type-only exemption. See decision 8, which removes them from the violation list.
scope:*: thirty-three files, of which the largest single cause is one library.
Thirteen edges, listed in full in ADR-0004. Two facts from the simulation shape the work:
shared/app-common-to-moveis a hub, not a leaf. Five different page scopes import it across eight files. It cannot simply be retagged, because any tag makes some of those edges illegal:scope:appbreaks all eight, andscope:sharedis a lie about a library whose fan-out exceeds its fan-in (ADR-0005). It has to be split before the constraint block is swapped, so it is on the critical path. What the pages actually take from it is four user-form components (UserForm,UserFormComponent,PasswordConfirmFormComponent,PasswordFormFieldComponent, used bysystemanduser),SearchTipsComponent(advanced-search),SplitPipe(ontology), and three header components.- Two page libraries import the application header.
pages/search/searchimportsHeaderComponentandpages/project/projectimportsHeaderLogoComponentandHeaderUserActionsComponent. The application shell does not own its own header exclusively; two pages render pieces of it. That is a genuine design question rather than a mechanical move, and it is the one part of the split that is not obvious.
The remaining scope violations are the cross-page symbols triaged in ADR-0004, and ontology -> project alone accounts for fourteen files (twelve in pages/ontology/ontology, two in pages/ontology/list), all importing ProjectPageService.
dsp-jsis tagged bothtype:data-accessandtype:util, as a named and expiring compromise. (static-analysis)
dsp-js is two libraries wearing one name: domain value types with no transport (KnoraDate, KnoraPeriod, Precision, StringLiteral, Constants) and an HTTP client (KnoraApiConnection and the v2 endpoints). Tagged only type:data-access, the eight ui -> dsp-js imports are illegal under decision 2, even though every one of them takes a value type.
The mechanism available is the tag list, not an exception list. @nx/enforce-module-boundaries in Nx 23 accepts only sourceTag or allSourceTags, onlyDependOnLibsWithTags, notDependOnLibsWithTags, allowedExternalImports and bannedExternalImports on a constraint. There is no per-constraint allow, and the two *ExternalImports keys govern npm packages rather than workspace libraries, so neither applies to an internal library. The top-level allow option does exist, but it exempts an import specifier from boundary checking everywhere, which is broader than intended.
So dsp-js carries both tags. type:ui -> [type:ui, type:util] is then satisfied, because a target needs only one of the listed tags. As a source, dsp-js must satisfy the type:data-access, type:util and scope:shared constraints simultaneously, which it does vacuously: it imports no workspace library at all, so its fan-out is zero.
The cost, stated plainly. The dual tag makes dsp-js importable from type:util libraries as well, which means nothing would stop a utility library from importing KnoraApiConnection. That is a real hole and it is accepted, because the alternative is either a global exemption or splitting a published package.
The compromise expires when the value types are separated from the transport layer into their own type:util library. That split is a breaking change for external NPM consumers of @dasch-swiss/dsp-js (ADR-0002, decision 5) and is therefore not scheduled by this ADR.
dsp-jsand3rd-party-services/open-apiare the twotype:data-accessroots, and3rd-party-services/apiwraps them. Which of the two clients carries which domain is already recorded inCLAUDE.mdand is not restated here.
Resulting constraint block
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{ sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
{ sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
{ sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui', 'type:util'] },
{ sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
{ sourceTag: 'type:e2e', onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
{ sourceTag: 'scope:app', onlyDependOnLibsWithTags: ['*'] },
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
{ sourceTag: 'scope:project', onlyDependOnLibsWithTags: ['scope:project', 'scope:shared'] },
{ sourceTag: 'scope:ontology', onlyDependOnLibsWithTags: ['scope:ontology', 'scope:shared'] },
{ sourceTag: 'scope:search', onlyDependOnLibsWithTags: ['scope:search', 'scope:shared'] },
{ sourceTag: 'scope:user', onlyDependOnLibsWithTags: ['scope:user', 'scope:shared'] },
{ sourceTag: 'scope:system', onlyDependOnLibsWithTags: ['scope:system', 'scope:shared'] },
],
},
],
Tag assignment
This is the table step 6(a) applies. It is the assignment the simulation in decision 7 was run against, so the violation counts there are only valid for these tags.
| Project | Tags |
|---|---|
apps/dsp-app |
scope:app, type:app |
libs/dsp-js |
scope:shared, type:data-access, type:util |
libs/vre/3rd-party-services/open-api |
scope:shared, type:data-access |
libs/vre/3rd-party-services/api |
scope:shared, type:data-access |
libs/vre/3rd-party-services/analytics |
scope:shared, type:data-access |
libs/vre/core/config |
scope:shared, type:data-access |
libs/vre/core/session |
scope:shared, type:data-access |
libs/vre/core/error-handler |
scope:shared, type:data-access |
libs/vre/shared/app-common |
scope:shared, type:data-access |
libs/vre/shared/app-helper-services |
scope:shared, type:data-access |
libs/vre/ui/ui |
scope:shared, type:ui |
libs/vre/ui/nested-menu |
scope:shared, type:ui |
libs/vre/ui/string-literal |
scope:shared, type:ui |
libs/vre/ui/date-picker |
scope:shared, type:ui |
libs/vre/ui/progress-indicator |
scope:shared, type:ui |
libs/vre/ui/notification |
scope:shared, type:util |
libs/vre/shared/calendar |
scope:shared, type:util |
libs/vre/resource-editor/resource-editor |
scope:shared, type:feature |
libs/vre/pages/data-browser |
scope:shared, type:feature |
libs/vre/shared/app-help-page |
scope:shared, type:feature |
libs/vre/shared/app-common-to-move |
none yet, see below |
libs/vre/pages/project/project |
scope:project, type:feature |
libs/vre/pages/ontology/ontology |
scope:ontology, type:feature |
libs/vre/pages/ontology/list |
scope:ontology, type:feature |
libs/vre/pages/search/search |
scope:search, type:feature |
libs/vre/pages/search/advanced-search |
scope:search, type:feature |
libs/vre/pages/user-settings/user |
scope:user, type:feature |
libs/vre/pages/system/system |
scope:system, type:feature |
Three entries need their reasoning stated, because none of them follows from the folder name.
core/config, shared/app-common and shared/app-helper-services are type:data-access, not type:util. Each vends stateful services or reaches the API: core/config provides KnoraApiConnection through dsp-api-tokens.ts, and app-common contains resource.service.ts. Tagging them util would be the convenient choice and would silence the type:ui violations, at the cost of letting any utility library import the API connection. The strict reading in decision 2 is worth nothing if the tags are bent to fit it.
shared/app-common-to-move is deliberately left unassigned. Decision 7 explains why: every available tag makes some of its eight importing files illegal, so it is split rather than tagged, and the split is a precondition for step (c).
pages/data-browser is scope:shared although it sits under pages/. It is a component library, not a routed page: apps/dsp-app/src/app/app.routes.ts never imports it, pages/project routes to it through DataBrowserPageComponent, and three page libraries embed its internals. ADR-0004 decision 7 records the reasoning. Tagging it honestly makes six cross-page imports across six files legal without any code moving.
Every project carries exactly one type: and one scope: tag, except libs/dsp-js, which carries two type: tags per decision 8.
Nx evaluates every constraint whose sourceTag appears in the source project's tags, and all of them must pass. Within a single onlyDependOnLibsWithTags list, the target needs only one matching tag. Those two facts are what make decisions 3 and 8 work: the scope and type blocks compose, and a dual-tagged target satisfies either.
Consequences
Positive. A contributor can read a library's tags and know what it may import, without reading its history. nx affected becomes sharper as the graph loses its false edges. Once page libraries stop importing each other, route-level lazy loading becomes possible for the first time; today it is blocked because every page is reachable from every other, so nothing can be split out of the main bundle. The seventeen cross-page symbols get triaged rather than accumulating (ADR-0004).
Negative and costs. Thirty-nine files must change before the constraint block can be swapped: six in the ui tier, thirty-three across page scopes. shared/app-common-to-move must be split first, and splitting it surfaces a design question about who owns the application header. The LocalizationService question is likewise unresolved, and both block step (c). dsp-js keeps a dual tag that will outlive this ADR, with the hole decision 8 describes. Tagging twenty-eight projects is mechanical but touches every project.json.
That cost is concentrated, not spread: the type tier is already clean under these tags, and essentially all of it is the feature-to-feature coupling ADR-0004 exists to remove. This ADR is therefore not independently landable. Step (c) depends on ADR-0004 and ADR-0005 being carried out first.
What this does not catch. Stating the limits is the point of recording an enforcement level.
- Sass is invisible. Twenty-eight library files reach into
apps/dsp-app/src/styles/, which inverts the app-to-library arrow. Twenty use an explicit relative path, and one of those twenty is not a stylesheet at all:pages/ontology/ontology/.../property-item.component.ts:130carries an inlinestyles:block that@uses the app's config. The other eight use a bare specifier,@use 'config','mixins'or'responsive', resolved bystylePreprocessorOptions.includePaths: ["apps/dsp-app/src/styles"]inapps/dsp-app/project.json. That form contains no trace ofapps/dsp-app, so searching for the path finds twenty of the twenty-eight and reads as clean progress. The Nx rule parses TypeScript imports only, so it catches none of them.
Promotion path (docs-only -> static-analysis). A CI grep covering both forms, failing on any hit under libs/:
grep -rnE '^[[:space:]]*@(use|import)[[:space:]]' libs --include='*.scss' --include='*.ts' \
| grep -E "apps/dsp-app/src/styles|['\"](ck-editor|config|elements|font|layout|mixins|responsive|typography)['\"]"
The second alternation is the basename list of apps/dsp-app/src/styles/_*.scss, which is what makes the bare form visible. It matches thirty-four statements in twenty-eight files today, so the gate lands after those files move, not before; until then this stays docs-only.
- templateUrl, styleUrls and assets entries are strings and are not analysed.
- Aliased deep imports into files a library does not export are not reliably blocked (Nx issue #29258, closed as "not planned"). ADR-0002 covers this and names the tool that would close it.
- Dynamic import() is analysed by the rule. There is no gap there, and no need to treat lazy loading as an escape hatch.
- Applications cannot be imported by anything, regardless of tags; Nx enforces this independently of depConstraints. So "nothing depends on the app" is free rather than earned, and type:app should not be credited for it.
Inherited configuration: useInferencePlugins: false
Recorded here because it looks like a decision and is not one, and because the next person to read nx.json will otherwise assume somebody weighed it.
nx.json sets "useInferencePlugins": false. That was written by an automated Nx codemod named update-18-0-0/disable-crystal-for-existing-workspaces, whose entire body is nxJson.useInferencePlugins = false;. It arrived in commit 1718fb0a7 on 2024-12-09, in a pull request titled "chore: update nx to v18.2.0 and angular to v17.3.0". The same diff carries the other codemods from that migration: npmScope removed, affected.defaultBase hoisted to defaultBase, cacheableOperations replaced by per-target "cache": true. Nx's intent was that upgrading should not silently change behaviour. The effect is that the pre-18 default has been carried forward through every migration since, and the workspace now runs Nx 23 on it.
The flag is narrower than its name suggests. It gates whether nx init and nx add register inference plugins in future. It does not switch off plugins already listed in plugins[], so @nx/eslint/plugin and @nx/jest/plugin both run.
The result is that target definition is split three ways, and the split follows no rule:
| Target | Declared in project.json |
Actually comes from |
|---|---|---|
lint |
1 of 28 projects | inferred by @nx/eslint/plugin |
test |
28 of 28, but with no executor |
inferred by @nx/jest/plugin; project.json only overlays options and configurations |
build, serve, e2e, storybook |
explicit, with executors | declared outright, pre-inference style |
Why this is worth fixing rather than tolerating. Reading a project's project.json does not tell you what targets that project has. A test entry with no executor is not a target definition, it is a patch applied to one declared somewhere else, and nothing in the file says so. Anyone reasoning about the workspace from its files, a new contributor or an agent working from the repository alone, has to already know which plugins are registered before the configuration means anything. That is the same failure mode as the empty "tags": [] arrays this ADR exists to fix: configuration that reads as a decision but is an artefact.
The cleanup is to register the remaining inference plugins (@nx/angular for build and serve, @nx/cypress/plugin, @nx/storybook/plugin) so that every target is inferred and project.json holds only genuine per-project overrides. That is deliberately not decided here. It is unrelated to boundaries, it touches every project.json, and doing it alongside the tag migration would make a broken build ambiguous about which change caused it. It is recorded as known technical debt with a known shape.
One interaction worth noting for ADR-0006: @nx/cypress/plugin infers an e2e target from any cypress.config.ts. If it were registered, the new dsp-app-e2e project would receive its target rather than declaring one. That changes the mechanics of ADR-0006, not its decision.
Alternatives rejected
- Leave
* -> *in place. Costs nothing today. It is what produced the eleven cross-page edges, thecore -> uiedge and twenty-three emptytagsarrays, and there is no reason to expect a different result from the same configuration. - Loose
type:ui(ui -> ui, util, data-access). Legitimises theLocalizationServicedependency and removes the need for thedsp-jsdual tag, so it is cheaper by roughly four files. Rejected because it also legitimises every future service injection into a presentational component, which is the drift this ADR exists to stop. The measured cost of strictness is six files, against thirty-three for the scope tier that must be paid regardless. Strictness in theuitier is the cheap part of this ADR, not the expensive one. - Express the feature-to-feature ban as a type constraint. Simpler to read, but it leaves a genuinely shared feature such as
resource-editorwith no legal home, forcing either an exception or an artificial split. Scope-level expression handles it without a special case. - Sheriff (SoftArc) instead of Nx tags. Sheriff enforces at folder level and closes the deep-import gap that Nx leaves open. It is not rejected on merit; it is deferred to ADR-0002, where the gap it closes is actually discussed. Adopting two boundary tools in one change would make a failing build ambiguous.
- Feature-Sliced Design. A real and well-documented methodology, but its tooling, documentation and community are React-centric and it has effectively no Angular presence. Adopting it would mean translating every rule ourselves, with no external material to point a new contributor at.