A workspace without rules is a folder in which every file may import every other. This lesson creates the Kladde's first libraries, gives them tags and makes the lint run fail when someone crosses a boundary. On top of that come a look at Sheriff and a test that checks the rules themselves. What a slice by domain logic is and how to find one is taught by W10.2 (and K4 lesson 4.2.4); here the subject is the craft of enforcing boundaries. The workspace from lesson 0101 is assumed.
State of the checks. Run (Node 24.22.3, Nx 24.0.6, Angular 22.2, Vitest 4.1.12): five libraries with
@nx/angular:library, tags and rules, three deliberate violations with the real lint result (below, paths shortened), the run without violations (exit 0), the probeboundary-probe.mtsincluding the counter-probe. Sheriff:npm viewand an installation in a throwaway project withinit,list,verify; what was read were sheriff.softarc.io, the project page on GitHub and an article from angulararchitects.io. Not run: Sheriff's ESLint plugin, Sheriff in the Nx workspace. Its documentation page returned 404 at the address we tried: open. The shapesContactSummaryandAppointmentResponsefollow the plan's contract; whether K2 names fields differently, check there (K2 2.3, 2.12).
Four kinds and two axes
On its page about folder structure (read), Nx names four kinds of libraries:
| Kind | Tag | Content | May depend on |
|---|---|---|---|
| Feature | type:feature |
components that carry a use case (page, routing) | every kind |
| UI | type:ui |
presentational components without application state | UI, utilities |
| Data access | type:data-access |
API access, state, the client's domain logic | data access, utilities |
| Utilities | type:util |
pure functions, types, helpers | utilities only |
Nx calls the table a starting point. The second axis is the domain (scope:). For the Kladde: scope:kontakte, scope:termine, scope:ausschreibungen and scope:shared for what is really shared.
Applications get type:app and scope:buero or scope:buchung. Every project carries exactly two tags, kind and domain; the names follow <domain>-<kind>-<name>: kontakte-feature-liste, kontakte-data-access, kontakte-ui, termine-data-access, shared-util-datum.
Creating libraries
One call per library. The tags go in one argument, separated by a comma; in PowerShell in quotation marks, otherwise Nx reported "Schema does not support positional arguments" for us:
npx nx generate @nx/angular:library libs/kontakte/feature-liste `
--name=kontakte-feature-liste --prefix=kl `
--importPath=@kladde/kontakte/feature-liste `
"--tags=scope:kontakte,type:feature" --style=scss --no-interactive
The same for libs/kontakte/data-access, libs/kontakte/ui, libs/termine/data-access and libs/shared/util-datum (tags as in the table, domain termine and shared). The generator creates project.json with "tags": ["scope:kontakte", "type:feature"], an entry in tsconfig.base.json under paths
(@kladde/kontakte/feature-liste points to libs/kontakte/feature-liste/src/index.ts), a sample component and, the first time, the Vitest plugin in nx.json. The libraries are not buildable (see 0302).
The entry under paths is the public interface: from outside, only what src/index.ts exports is reachable. Our data access library exports export type { ContactSummary } from './lib/contact-summary';, the UI library a card that takes a small shape of its own:
export interface KarteInhalt {
readonly name: string;
readonly firma: string;
}
// ... component KontaktKarte with readonly inhalt = input.required<KarteInhalt>();
The UI kind must not depend on data access; the feature library knows both and translates. The application loads it lazily: loadComponent: () => import('@kladde/kontakte/feature-liste').then((m) => m.KontaktListe) in app.routes.ts.
A stumbling block: nx test of a library without a test file ended with exit 1 for us ("No test files found"). A library with only types needs the line passWithNoTests: true under test in its vite.config.mts (that is how we solved it for both data access libraries); one with code gets a real test.
Writing down the rules
The rule @nx/enforce-module-boundaries (package @nx/eslint-plugin) is in eslint.config.mjs at the root. It reads the tags from the projects and compares them with depConstraints: each entry names a sourceTag and the tags it may depend on (onlyDependOnLibsWithTags). If a project carries several tags, all matching entries apply, which is how two axes work:
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: 'scope:buero', onlyDependOnLibsWithTags: ['scope:kontakte', 'scope:termine', 'scope:shared'] },
{ sourceTag: 'scope:kontakte', onlyDependOnLibsWithTags: ['scope:kontakte', 'scope:shared'] },
{ sourceTag: 'scope:termine', onlyDependOnLibsWithTags: ['scope:termine', 'scope:shared'] },
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
],
This replaces the template's entry { sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }, which allows everything. According to Nx's page, tags may also be patterns (scope:*, regular expressions), and a project without tags may not depend on any other, unless an entry explicitly allows *.
We saw that: with the new rules and an application without tags, the lint run reported in app.routes.ts (line 6, the dynamic import):
6:26 error A project without tags matching at least one constraint cannot depend on any libraries @nx/enforce-module-boundaries
So the rule also checks dynamic import(). After "tags": ["type:app", "scope:buero"] in the application's project.json, nx run-many -t lint ran over all six projects with exit 0.
Provoking a violation
Create three files, each with a forbidden import:
// libs/kontakte/ui/src/lib/verboten.ts
import type { ContactSummary } from '@kladde/kontakte/data-access';
// libs/kontakte/feature-liste/src/lib/verboten.ts
import type { AppointmentResponse } from '@kladde/termine/data-access';
// libs/kontakte/feature-liste/src/lib/tief.ts
import type { ContactSummary } from '../../../data-access/src/lib/contact-summary';
(Each file additionally exports the imported type under a name.) npx nx run-many -t lint ended with exit 1 and these messages (paths shortened, lines wrapped):
libs/kontakte/ui/src/lib/verboten.ts
1:1 error A project tagged with "type:ui" can only depend on libs tagged with "type:ui", "type:util"
@nx/enforce-module-boundaries
libs/kontakte/feature-liste/src/lib/tief.ts
1:1 error Projects cannot be imported by a relative or absolute path, and must begin with a npm scope
@nx/enforce-module-boundaries
libs/kontakte/feature-liste/src/lib/verboten.ts
1:1 error A project tagged with "scope:kontakte" can only depend on libs tagged with "scope:kontakte", "scope:shared"
@nx/enforce-module-boundaries
The first case violates the kind, the third the domain, the second the public interface: whoever reaches past index.ts into the folder is rejected. As failed, the run named kontakte-ui and kontakte-feature-liste. Delete the files again. The graph (nx graph --file=graph.json, graph.dependencies) shows: kladde-buero depends on kontakte-feature-liste, and that on kontakte-data-access and kontakte-ui.
Sheriff as an alternative
Sheriff is a tool for module boundaries in TypeScript. Read: the home page names "zero dependencies" and, in its notes, version 0.19; the project page on GitHub names MIT, the installation npm i -D @softarc/sheriff-core @softarc/eslint-plugin-sheriff and index.ts files as the public interface; an article from angulararchitects.io says Sheriff enforces boundaries at the folder level for any TypeScript project.
Determined (the plan's gap): npm view @softarc/sheriff answers with 404; a package of this name does not exist. There is @softarc/sheriff-core (0.20.0, MIT, peer typescript >=4.8, program sheriff) and @softarc/eslint-plugin-sheriff (0.20.0, MIT, peer eslint 8, 9 or 10). The version is unclear: npm says 0.20.0, the home page's notes say 0.19; which page is current is open.
Tried in a throwaway project with src/kontakte and src/termine (one index.ts each): sheriff init src/main.ts produced a sheriff.config.ts with enableBarrelLess: true and all modules as noTag. An import past the barrel (../kontakte/lib/api) made sheriff verify end with exit 1 ("Total Encapsulation Violations: 1"). With the configuration below and an import through the barrel, verify reported (exit 1) "from tag domain:termine to tags domain:kontakte":
import { sameTag, SheriffConfig } from '@softarc/sheriff-core';
export const config: SheriffConfig = {
modules: { 'src/<domain>': ['domain:<domain>'] },
depRules: { root: 'domain:*', 'domain:*': sameTag },
};
When to use which? Nx draws boundaries between projects, Sheriff between folders. Whoever has Nx and cuts libraries has the rule in the same tool; Sheriff fits where there is no Nx workspace. We did not try both together.
What boundaries do not achieve
The rule checks imports and nothing else. It does not catch:
- wrong tags and wrong slices. A project filed in the wrong place passes every probe; a wrongly cut domain only becomes more expensive through rules (W10.2).
- coupling without an import. A string key, a global variable, an event on
windowor the same API URL from two domains does not show up in the graph. - rules that nobody checks. Only when the lint run counts in CI (lesson 0103) is a boundary more than a wish.
A test for the rule set
A rule set that cannot be checked gets loosened unnoticed. A probe lints small pieces of source as if they lay in a library and checks whether the rule fires. Node 24 runs .mts files directly. tools/boundary-probe.mts (scaffold; probes has three entries: ui to data-access and kontakte to termine with mustFail: true, feature to data-access of the same domain with false):
import { ESLint } from 'eslint';
const eslint = new ESLint({ cwd: process.cwd() });
let wrong = 0;
for (const probe of probes) {
const [result] = await eslint.lintText(probe.code, { filePath: probe.file });
const failed = (result?.messages ?? []).some((m) => m.ruleId === '@nx/enforce-module-boundaries');
const ok = failed === probe.mustFail;
console.log(`${ok ? 'ok ' : 'WRONG'} ${probe.name}`);
if (!ok) {
wrong++;
}
}
process.exit(wrong === 0 ? 0 : 1);
node tools/boundary-probe.mts: for us three lines ok, exit 0. Counter-probe: we additionally allowed type:data-access for type:ui; the probe printed WRONG for the first case and ended with exit 1.
Exercise 1
Create libraries, set tags, see violations, write the probe.
- Create the five libraries with the names, import paths and tags from the text and give
kladde-buerotype:appandscope:buero. - In
eslint.config.mjs, replace the permissive rule with thedepConstraintsfrom the text; exportContactSummary,KontaktKarteandKontaktListethrough theindex.tsand have the application load the list lazily. - Provoke the three violations, read the messages, delete the files. Then run
nx run-many -t lint test build. - Write
tools/boundary-probe.mtswith the three probes, run it, loosen the rule fortype:ui(counter-probe) and restore it.
How to check: with the violations, lint ends with exit 1 and names kontakte-ui and kontakte-feature-liste; without them exit=0. The probe: exit=0 with three ok lines, with the loosened rule exit=1 and one line WRONG.
Solution 1
- "Schema does not support positional arguments": tags in quotation marks (
"--tags=scope:kontakte,type:feature"). - "A project without tags matching at least one constraint cannot depend on any libraries": the importing project has no tag that matches a
sourceTag;kladde-bueroneedstype:appandscope:buero. testof a library ends with exit 1 ("No test files found"): add a test or, for pure types,passWithNoTests: true.- The probe reports
WRONGeverywhere:filePathmust lie in a project folder, and it runs from the root of the workspace.
What you can do now
- You sort libraries by kind (
type:) and domain (scope:) and create them including tags and a public interface. - You write
depConstraintsfor both axes, have the lint run reject a forbidden import and read the message; you know that dynamic imports are checked too and that a project without tags may depend on nothing. - You place Sheriff (
@softarc/sheriff-core,@softarc/eslint-plugin-sheriff). - You name what boundary rules do not achieve, and you check the rule set with a probe and a counter-probe.
Further reading
- Nx: Enforce module boundaries: tags,
depConstraints, forms of the tags. - Nx: Monorepo folder structure: the four library kinds and grouping by domain.
- Sheriff, home page and Sheriff on GitHub: features, installation, license.
- angulararchitects.io: All about DDD for Angular & Frontend Architectures: read in the text; it presents Nx (boundaries per library) and Sheriff (per folder) as means to enforce domain boundaries in the front end, and stresses that what matters is the domain logic of the slices.
The Kladde: five libraries, two tags per project, one probe. Next comes lesson 0103: task pipeline, cache and nx affected in CI.