01. The starting point
The application is an enterprise customer portal for an EV platform: a dozen business domains,
each with its own screens, services and in several cases its own store. Angular 21 with standalone
components, TypeScript 5.9, NGXS where a domain holds state, Angular Material, Jest 30, Azure
DevOps pipelines. About 1,600 source files and 4,400 tests. A busy codebase: roughly 190 merges to
main in the six months before the migration, from eight regular committers.
Structurally it was one Angular CLI application. src/app held 18 domain folders. Sixteen
of them were organised by technical kind (components/ services/ models/ resolvers/ store/
utils/), and a shared folder had grown into the thing every architecture document
forbids: 41 component folders, several with *-new twins left over from redesigns,
imported by 17 of the 18 domains through 1,219 import statements. A tracked architecture document
described a Feature-Sliced Design layout. Two folders followed it.
The import graph was where it hurt. Measured on the day the migration spec was written:
| Measure | Value | Note |
|---|---|---|
| Cross-scope production imports | 16 | one domain reaching into another's folder |
Upward imports from shared | 10 | shared importing four domains |
| Two-way edges | 10 | three domains importing each other in a ring, shared importing its own consumers |
@NgModule files | 28 | 10 lazy feature modules, 10 routing modules, 8 wrappers |
| Services provided only by a module | 32 | 7 features lazy-loaded at two or three paths each |
| ESLint errors / warnings | 288 / 4,576 | ESLint 8, not gated in CI |
| PR pipeline, median | 11.3 min | Jest 4.6 – 6.1 min of it, the whole suite on every PR |
Nothing enforced direction. ESLint had no boundary rule, the tsconfig aliases pointed
at folders, and a reviewer who did not already know the intended layering had no way to tell an
upward import from a normal one. The folder shape was a convention, and a convention does not fail
the build.
02. Why Nx, and why not just folders
We wanted two things: a mechanical boundary check that fails a pull request, and CI that runs only
what a pull request touches. Nx gives both through the same primitive, the project graph.
@nx/enforce-module-boundaries reads it to check tags; nx affected reads it to
pick tasks. A folder-level tool such as Sheriff would have given us the first. Nothing short of
separate projects gives the second, because the unit nx affected schedules is a project,
and one root Jest project means every PR runs everything.
There was a tempting middle path: add an ESLint boundaries plugin over the existing folders and postpone the move. I wrote it up as an architecture decision record and rejected it. An interim boundary tool would have encoded the current folder graph, including its ten upward edges, as law, and we would have migrated twice. One rule from that record survived everything that followed:
Never restructure a domain in passing. A move is its own change with its own diff.
A third motivation is specific to 2026. A few weeks earlier I had put a Claude Code harness on the
repository: project rules, skills, a planner, implementer and reviewer subagents, and a verification
script that runs type-check, tests, lint parity and a conventions check in one command. A
restructure of this size is mostly mechanical: hundreds of git mv operations, thousands of
rewritten import paths, dozens of near-identical project configuration files. That is exactly the
work agents do well when the rules are explicit and the gates are mechanical. The ordering was
deliberate: harness first, then a restructure planned and executed with agents rather than by hand.
Section 10 has the ledger.
03. The plan: one phase, one PR
The spec fixed the target (one library per domain and layer, tags, an enforced layer order, CI on
affected) and the roadmap fixed the order. Every phase was its own pull request against
main, shippable on its own, with feature work merging between them.
| Merged | Phase | What changed |
|---|---|---|
| 18 Sep | −1 · NgModules out | 28 modules → route-scoped providers; 57 specs rewritten; a spec guards every bare @Injectable() |
| 23 Sep | 0 · ESLint 9 flat config | angular-eslint 21, typescript-eslint 8; error count pinned at 288 before and after |
| 24 Sep | 1 · Nx, zero file moves | nx init, angular.json → project.json, the shared testing library as the first library; build output byte-identical, second build a cache hit |
| 24 Sep | 2 · Shared split | src/app/shared → five libraries; a TypeScript-API codemod rewrote the imports; cross-scope edges 16 → 0 |
| 24 Sep | 2 · Boundaries | @nx/enforce-module-boundaries with the tag table, runnable as one npm script |
| 25 Sep | 2 · Sonar on libs | the pipeline template reads coverage for libs/; lcov files merged |
| 25 Sep | 3a · Leaf domains | the four domains that import only shared: 6 libraries, 229 renames |
| 28 Sep | 3b–3e + 4 · Domains and the app move | the remaining eight domains in four waves; the app shell becomes a library; src → apps/<app>; feature libraries re-sliced by screen. 54 commits, 1,230 files |
| 28 Sep | 5 · CI on Nx | boundaries gate in the PR pipeline; 37 Jest projects; nx affected; summing lcov merge |
| 29 Sep | 6 · Shared rework | domain code out of shared; the scope rule relaxed; 35 → 42 libraries; the last ignored cycle removed |
Three decisions set the order:
- ESLint 9 first, because the boundaries rule lives in ESLint and the repo was still on 8 with a legacy config.
- The app moves last. Renaming the root collides with every open branch and buys nothing until libraries exist. When it finally happened, every open branch rebased once, on an announced day.
- Leaf domains first. A domain that imports only
sharedcan become a library without touching anyone else. The tangled ones (three domains that imported each other in a ring) waited until the pattern for breaking a cycle was proven on something smaller.
Three invariants held at every phase, and each PR had to prove them:
- Nothing changes on screen. No route path, guard, copy or behaviour moves.
- The pipeline contract is untouched. The shared pipeline templates call exactly three npm scripts: install, test, build. The scripts absorb the
nxcommands; a second repository on the same templates stays green. - The build output is byte-identical to
main's, except the version stamp, or the PR explains the difference with the chunk list.
Phase −1: NgModules become route-scoped providers
28 NgModules, ten of them lazy feature modules whose injectors were the only thing providing 32 services. A library that exports an NgModule is a worse library than one that exports a routes array, and moving the modules into libraries would have meant doing the move twice. The harness rules at the time said to leave them alone until the migration; I overruled that.
Each lazy domain got a routes file that exports its routes and a <domain>Providers
array, registered on a componentless path: '' root route. That gives one route injector
per loadChildren site, which is what the module injector used to give. The shape, not a
real file:
the shape of a domain's routes fileexport const ordersProviders: Provider[] = [OrdersService, OrderDraftService, OrderResolver];
export const ordersRoutes: Routes = [
{
path: '',
providers: ordersProviders,
children: [
{ path: '', component: OrderListComponent },
{ path: ':id', component: OrderDetailsComponent, resolve: { order: OrderResolver } },
],
},
];
What you lose with modules is the compile-time guarantee that a service is provided somewhere. A
bare @Injectable() that nobody lists is a NullInjectorError at runtime, on the
one screen that uses it. So a spec replaced the guarantee: it walks every .ts file, finds
every @Injectable() without providedIn, and fails unless the class name appears
in some providers array, providers constant or provideStates([ call. It guarded 43 classes
on day one and still does.
Phase 1: Nx with zero file moves
nx init on an Angular CLI workspace is quick and slightly too helpful. It rewrote five npm
scripts, added four dev dependencies, and treated a helper folder with its own
package.json as a project. The fixes were small: an .nxignore line,
includedScripts: [], scripts restored. The acceptance criterion was the one that mattered:
the Docker build produced the same 183 files and 53 lazy chunks as main, byte for byte,
and a second nx build was a cache hit.
The cache then taught the first lesson. The build reads an environment template at build time through a small script, and that template was not a declared input, so a changed environment hit the cache and shipped the old values.
A runtime input in nx.json that hashes the template, so a change to it is a
cache miss. Nx cache correctness is exactly as good as your declared inputs, and every later cache
surprise (section 5) came down to the same sentence.
Phase 2: the shared split and the untangle
src/app/shared became five libraries (ui, data-access,
util, and two topical ones that later disappeared, see section 6).
The ten upward imports were fixed in the same PR by lifting the symbols shared had
reached for. This was the PR where hand-editing stopped being an option: the move commit touched
1,029 files, and 313 of them ended up with duplicate import lines that a second pass merged.
The codemod is about 300 lines on the TypeScript compiler API, no new dependency. Given a manifest of
folder → library moves, it resolves each barrel import through the old
shared/index.ts and its sub-barrels to find which new library owns the symbol, rewrites
deep imports by path, and refuses to produce a cross-library relative path. Every wave after this one
used it with a manifest and produced one commit per domain.
A library cannot import the app's environment.ts, and the Angular
fileReplacements mechanism only works on files the app owns. So the environment and the
runtime configuration became two injection tokens, provided in main.ts and injected
everywhere else. The app provides the values; a library injects them. This is the only way a
library reads configuration.
The initial bundle grew by 4 % after the split (6.01 → 6.25 MB raw in the Docker configuration):
barrels plus esbuild's conservative side-effect analysis keep more in the initial chunk. A probe
with sideEffects: false on each shared library halved the initial bundle (3.30 MB,
127 chunks) after an audit of top-level side effects. It is an open follow-up with its own QA
click-through, not something to slip into a restructure PR.
Phases 3 and 4: domain libraries, leaf first, then the app
Per domain the recipe was the same, and the same five files (project.json with its two
tags, the two tsconfigs, a Jest config, the barrel) were copied from a neighbour rather than
generated. git mv the folder into src/lib, write a barrel that exports the
routes array, the providers and whatever another library imports, switch the
loadChildren in the app routes to the alias, run the codemod, run the gates. Five waves
moved twelve domains into 35 libraries in three days. The last wave moved the app shell (layout,
guards, interceptors, the root routes) into a library of its own type, and then git mv
moved the application under apps/ with 19 configuration files re-pointed.
nx g @nx/workspace:convert-to-monorepo looked like the obvious tool for the app move.
A dry run showed 1,441 operations that would have nested the existing libraries under
libs/libs/ and renamed every import alias. The hand move was two commands and a
diff we could read.
04. The tag table
Every library carries two tags: scope:<domain> and type:<layer>.
Nine scopes (shared, shell, and seven business domains), six types. The
alias is @org/<scope>/<name>, the folder libs/<scope>/<name>,
the project name <scope>-<name>. The whole rule set is one ESLint config with
this constraint table:
the boundaries config, depConstraints[
{ sourceTag: 'type:app', onlyDependOnLibsWithTags: ['*'] },
{
sourceTag: 'type:shell',
onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util', 'type:testing'],
},
{
sourceTag: 'type:feature',
onlyDependOnLibsWithTags: ['type:ui', 'type:data-access', 'type:util', 'type:testing'],
},
{
sourceTag: 'type:ui',
onlyDependOnLibsWithTags: ['type:ui', 'type:data-access', 'type:util', 'type:testing'],
},
{
sourceTag: 'type:data-access',
onlyDependOnLibsWithTags: ['type:data-access', 'type:util', 'type:testing'],
},
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util', 'type:testing'] },
{
sourceTag: 'type:testing',
onlyDependOnLibsWithTags: ['type:testing', 'type:data-access', 'type:util'],
},
{ sourceTag: 'scope:shell', onlyDependOnLibsWithTags: ['*'] },
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
]
// plus a no-restricted-imports block: @org/*/testing only from *.spec.ts files.
// run with --no-inline-config --max-warnings 0 over apps/ and libs/.
ui, data-access and util
in any scope and never another feature. Testing libraries are reached from spec files only. The
counts are the workspace after the last migration PR.
A few rows need a sentence each, because each one came out of a specific wave.
-
A feature never imports another feature. A domain is reused through its
data-accessoruilibrary. The first wave hit this immediately: a lookup screen in one domain is also rendered inside another domain's screen, so that domain shipped as afeatureplus auilibrary. That became the pattern for every later wave. -
A routeless widget another domain renders is
ui+data-access, never a feature. One configuration widget has no routes of its own and is only ever rendered inside another domain's screen. It became auilibrary and the first domaindata-accesslibrary in the workspace. -
type:shellexists because the rule checksimport()too. The shell lazy-loads every feature. Taggedtype:featureit would fail at everyloadChildrensite, so it gets its own row that may reach features, and nothing else may. -
type:testingis spec-only. Test generators live inlibs/<scope>/testing; ano-restricted-importsblock in the same config keeps the alias out of every non-spec file. Why this is a separate type and not a folder is in section 5. -
sharedimports onlyshared. It holds what no domain owns: the HTTP layer, the feature-flag service, the table helpers, the design-system wrappers. The day a shared library imports a domain, it stops being shared. -
The gate cannot be silenced. The boundaries lint runs with
--no-inline-config, so aneslint-disablecomment has no effect, and with--max-warnings 0. It takes under four seconds on the whole workspace and is a required step of the PR pipeline. There are no allowances in the config. The one spec-only exception that existed for four days is gone (section 6).
05. Challenges the project graph set us
Most of the surprises were about what the graph sees that nx graph does not draw, and
what it does not see at all. Each one cost a few hours the first time and is now a sentence in the
rules.
The rule reports upward imports as cycles
In phase 1 we concluded that Nx drops every edge from a library into the root application, because
nx graph does not draw them. In phase 2 the rule proved otherwise: it reads the cached
project graph, and those edges are there. Because the rule's cycle check runs before its "no
imports of apps" check, a library that imports the app is reported as Circular dependency
between …, not as an upward import. One deliberate upward import in a probe produced 88 cycle
errors across the libraries that transitively depended on it.
Nothing to fix in the tool, only in the reading: "circular" almost always means "someone imported upward". The rule is written down next to the gate, and the fix is always to lift the symbol the library wanted into a lower layer.
A typeof import() is an edge
The Jest setup file in the app mocked a shared service with a
jest.mock<typeof import('…')> type argument. That single type-position import made
the app a dependency of every library whose tests load the setup, and the rule produced several
hundred findings from it.
import type * as, which the rule ignores. The graph counts spec files and setup files; the
boundaries do not stop at .spec.ts, and a plan now greps for type-position imports too.
The lazy-library trap
A feature library exists to be lazy-loaded. If any file in the shell imports it statically, two
bad things happen at once: the boundaries rule flags every import of that library (the rule
applies the constraints to import() and to static imports alike, and a static import
of a lazy project changes the edge), and esbuild pulls the whole chunk into the initial bundle.
One wave hit this with a service that the root routes file provided but a lazily loaded screen
owned.
The service moved into the domain's own providers on a componentless root route inside the library, the first time the rule forced a route-tree edit. No public path changed, and the chunk list came out identical.
A static import of a lazily loaded library is a lint finding and a bigger initial bundle. Both are the same mistake.
Spec imports are edges, so generators are their own type
The first cut put test generators in a testing/ folder inside each
data-access library and widened the "specs only" ignore to cover it. That pulled eleven
app files into every data-access library's production compile, because the generators imported
fixtures the app owned.
Generators got their own libraries, libs/<scope>/testing, tagged
type:testing. A testing library depends on its scope's data-access for the
model types. The reverse, a data-access spec importing its own scope's testing library, is a cycle,
because Nx counts the spec import. So a data-access spec builds its fixtures with the generic
generator helpers from the shared testing library and literals, and the rule is written down where
the next person will look.
Sass edges are invisible
A @use '../../../other-domain/styles/partial' is an import the project graph never
sees. One wave found a 206-line partial that two other domains reached by relative path, visible
only in the build output after the move.
A partial several libraries use lives in a styles/ folder at the root of a
type:ui library and is reached as @use '<scope>/<name>/styles/<partial>'
over an includePaths entry for libs. A small spec walks every
.scss under libs and fails on a @use that resolves into the app or
into a feature library. A wave plan greps .scss as well as .ts.
Stale graphs look green
Two cache lessons. A library spec edit did not invalidate the root test target until the inputs named everything a test actually reads. And the Nx daemon's graph goes stale after a library is renamed or created, and silently drops the library from a run: a green verification that did not include the project you just made.
The test target's inputs name the preset, the setup files and the translation fixture the tests
assert against. The verification script sets NX_DAEMON=false, and a run is read against
the list of projects it was supposed to include.
06. Two rules we reversed
Where a store lives
Wave 3b shipped one domain with its NGXS store inside the feature library. The spec said "stores stay with their domain", and nothing else read the store, so the feature was its domain. Looking at the result: a feature library is routed screens; a store is data access whatever the consumer count, and the moment a second screen needs it you are moving it under pressure.
A domain's NGXS quartet (actions, selectors, state, store) lives in its
data-access library even when only its own feature reads it;
provideStates([...]) stays in the feature's routes file, which imports the state class
through the alias. The rule applies by kind: a signal service whose job is held state follows the
same path. Three stores moved in the next two waves.
This is stricter than what Manfred Steyer currently recommends. His recent demos keep a store used
by one feature inside the feature and move it to data only when several features share
it. His 2023 matrix allows state in data modules, so both are compatible with the same layer order.
I prefer the stricter rule for a team: it removes a judgement call, and the cost (one more library
alias in a routes file) is nothing.
"A cross-scope need is lifted into shared"
This was the original scope rule, and it is the one I would warn anyone about. The first tag table
said scope:<x> may depend on scope:<x> and
scope:shared, nothing else. Clean on a slide. In practice every entity model that two
domains share (and the platform's central entity is referenced by five domains) had nowhere to go
but shared/util, and every cross-domain service call went through a 24-method façade in
shared/data-access. Three domains ended the waves with no data-access
library at all, because their models were already in shared. The audit after the
domain waves:
| After the domain waves | Value |
|---|---|
Library source files in libs/shared | 442 of 1,631 |
Share of test suites affected by a shared/util change | 99 % |
| Methods on the shared façade | 24 |
Domains without a data-access library | 3 of 7 |
A shared that owns the domain models makes nx affected worthless, because
everything is affected.
The last PR of the migration changed the rule:
Only
featureandshelllibraries are scope-private. A scope reaches another scope'sdata-access,ui,utilandtestingdirectly.
The seven scope:<domain> rows left the config. The type rows still forbid importing a
feature from anything but the shell, so the ladder holds. The façade split into one API class per
domain, each in its domain's data-access. The models went home. Seven libraries were
created, one topical shared library was deleted and absorbed, and the count went from 35 to 42. The
remaining cross-scope data-access edges are one-way and listed in the architecture record, so a new
edge is a visible change to a short list.
The same PR removed the last ignoredCircularDependencies entry. A first draft of the
rework had added eight ignore pairs to get a wave green; I rejected the lot. Cycles are prevented by
pattern, not by exception, and the four patterns are short enough to remember:
- Layers point down only.
- A
<scope>/testinglibrary depends on its scope'sdata-accessfor types, never the reverse. - A type that names two domains lives in the downstream library, the one that already depends on the other.
- Two features that need each other's screens share a
uilibrary, never each other.
We cross-checked the result against Steyer's published material before merging, because his
"strategic design" matrix is the thing most Angular teams reach for. His cross-cutting area is a
shared domain typed like any other domain, reachable from everywhere, which is what we
have. He has never used core, so a rename that came up in review was dropped. His
modern data libraries are flat; ours keep store/ services/ models/ consts/ utils/
inside data-access, which the review called "contradicted as a default, tolerated at
size". With a dozen services and a four-file store per domain, the folders stay.
07. Business slices inside a feature library
Moving a folder into a library does not change what is inside it. After the waves, eleven feature
libraries had a top-level components/, nine a services/, ten a
models/. A library that is "the screens of domain X" and opens with
components/ services/ models/ resolvers/ tells you nothing about which screens exist.
One more pass, 852 renames across 13 libraries, re-sliced every feature library by screen:
before → after, one feature librarylibs/<scope>/<domain>-feature/src/lib/
components/ → list/
services/ → details/
models/ → edit/
resolvers/ → create-dialog/
<domain>.routes.ts <domain>.routes.ts
<domain>.service.ts ← two slices share it
<domain>.resolver.ts
<domain>-view.model.ts
A feature library is the routes file plus one folder per screen or use case; the routed component
sits flat in its slice; a file two slices share sits flat at the library root; nothing technical at
the top. data-access libraries keep their technical folders, because a service layer
really is organised by kind. Steyer's own trees (one folder per component, routes file and feature
service flat beside them, no components/) say the same thing, and his 2026 "feature
slicing" posts make the argument better than I will here.
The shape is held by a spec with one row per library:
- a
featurelibrary has no top-level folder from a list of twelve technical names or seven FSD segment names; - a
data-accesslibrary has onlystore/ services/ models/ consts/ utils/; - a
shared/*library imports no domain alias, including insidejest.mockstrings; - a feature library has no root file whose importers all sit in one slice (it belongs in the slice);
- there are at least 42 libraries, a floor that only goes up.
That last row is the pattern for every guard in the repository: a count that never decreases. A guard with a fixed number rots the day someone deletes something legitimately. A guard with a floor that moves up with the codebase catches the deletion that was not legitimate.
08. CI on Nx: affected, per-project Jest, the gate
Up to here the pipeline had not changed: the templates called the same three scripts, and the scripts
ran nx build and nx test on one project. The point of the libraries is to run
less, and that needs two things: a project graph with many test targets, and a base commit to diff
against.
Per-project Jest, measured before it was committed
One root Jest project with 406 spec files is one task to Nx. Splitting it into 37 projects is more process start-up (each Jest project boots its own worker pool and the Angular preset) in exchange for running only the affected ones. Whether that is a win depends on the PR mix, and nobody could say from first principles.
We measured the split in the pipeline's own container (4 CPUs, Node 24, cold each run) on six PR shapes before any of it was committed, and weighted the result by the last six months of real PRs:
| PR shape | One root project | 37 projects, affected | Projects run |
|---|---|---|---|
Full suite (main) | 117.8 s | 175.0 s | 37 |
| Single domain library | 117.8 s | 21.2 s | 3: the library, the shell, the app |
shared/ui change | 117.8 s | 154.8 s | 23 |
shared/util change | 117.8 s | 173.7 s | 36 |
| Docs only | 117.8 s | 0 s | 0, exits 0 |
| Weighted by the real PR mix | 117.8 s | ≈ 85 s | 39 % domain-only, 39 % shared, 7 % global, 13 % no source |
The worst case is 1.49× slower than before, against a bar we had set at 1.4×. We shipped it anyway,
with the reasoning on the story: the average PR is 17 – 28 % faster, a shared-library PR pays about a
minute, and the shared rework the following day moved most of the code that made
shared/util changes affect 36 projects. The honest number still to collect is the
wall time of real PRs against the old 11.3-minute median; three timed PRs are on the list.
It took three rounds to get coverage right, and the second round would have passed a casual
review. Per-project rootDir instruments only that project's own files, so 91 files
exercised only by other projects' specs had no coverage record, and shared files lost their
cross-project hits. Not a single test went red.
The shape that works keeps rootDir at the workspace and limits discovery with roots:
one of 37 Jest configs, identical shapeexport default {
displayName: '<scope>-<name>',
preset: '<rootDir>/jest.preset.js',
rootDir: '../../..',
roots: ['<rootDir>/libs/<scope>/<name>'],
coverageDirectory: '<rootDir>/coverage/libs/<scope>/<name>',
} satisfies Config;
The lcov reporter gets projectRoot so paths are workspace-relative, nx.json
names the coverage output explicitly (the Jest plugin's inferred output ignores
rootDir), and a small script sums the line, branch and function hits per source file
across the 37 lcov files before Sonar reads one merged report. Line coverage after the merge matched
the single-project number to within four lines. 37 hand-typed eight-line configs are a copy hazard,
so a spec checks each one against its folder.
Affected, and the gate
The test script branches on whether a base commit is set: with one it prints the affected project
list and runs nx affected -t test, without one it runs every project; both end with the
lcov merge. The PR template gained two boolean parameters, default off so the other repository on
the same templates is untouched: one adds the boundaries lint between install and test, the other
makes the checkout full-depth and exports the base as the merge base with the target branch. The
main pipeline sets no base, runs everything, and feeds Sonar the full merge. The lint
step takes about four seconds and fails a PR with ESLint's own message, which is exactly as readable
as you would hope: A project tagged with "type:data-access" can only depend on libs tagged with
"type:data-access", "type:util", "type:testing".
Remote cache: skipped, on purpose
cache: true only helps on a developer machine; the agent containers start clean. A remote
cache would change that. Nx Cloud sends task hashes and outputs outside the organisation and needs a
security sign-off; the Azure Storage cache package is deprecated upstream; a self-hosted server
implementing the remote-cache API is real work. The story's criterion allowed "an explicit decision
to skip, recorded", and that is what happened: revisit after three months of affected numbers. I
would rather have the measurement than the feature.
09. Guards, not guidelines
Every rule in this article that matters is held by something that fails, not by a document. The documents exist (an architecture record per decision, a tracked blueprint, a per-scope summary), but they describe the guards; they are not the guards.
| Guard | Fails when | The failure it prevents |
|---|---|---|
| The boundaries lint (CI, ~4 s) | an import crosses the tag table, forms a cycle, or reaches a testing library from production code | feature-to-feature coupling, upward imports, a lazy chunk in the initial bundle |
| The library-shape spec | a feature library grows a technical folder; shared imports a domain; the library count drops | the old folder layout coming back one folder at a time |
| The DI census spec | a bare @Injectable() is in no providers array | a NullInjectorError on one screen, after the module injectors went |
| The Sass-edge spec | a @use resolves into the app or into a feature library | a style dependency the project graph cannot see |
| The Jest-config-shape spec | a Jest config names a different project than its folder | a mis-rooted project passing with passWithNoTests |
| Injection tokens for environment and runtime config | a library tries to import the app's environment file (which the boundaries lint then reports) | a library that only compiles inside this one app |
| Build parity in every restructure PR | the chunk list or the initial size moves more than 1 % | a move that silently changed what loads when |
The rule for adding a structural rule is now the inverse: a structural change names the guard that would fail if it were violated, or says plainly that none exists. "Says plainly" is doing a lot of work in that sentence. It is where most of the guards above came from.
10. Running the migration with AI agents
Nobody typed most of this migration. I wrote the spec, made every ruling, verified every PR by hand before it opened, and reviewed the diffs. Agents investigated, planned, moved the files, rewrote the imports, ran the gates, and reviewed each other. The harness is Claude Code with a repository-specific layer on top:
- Roles. An investigator traces a ticket end to end and writes a facts note; a spec-writer turns the brainstorm into a spec with acceptance criteria; a planner writes tagged tasks; implementers (one for judgement work, one for recipe work such as a routes file or a rename) each take a task brief, verify and commit from a pathspec; a conventions reviewer and a code reviewer read each new shape; a PR creator opens the pull request only after a human has verified the branch.
- Tiers. A change is sized at the brainstorm: S (three files, no spec), M (one screen or contract, at most five tasks), L (a library, a cross-scope change, a wave). Every migration phase was an L.
- Gates in one command. A verification script runs app and spec type-checks, the related Jest projects, ESLint parity against the base (no new errors on touched files, since
maincarries 288), a conventions pass (translation-key parity, added comments, addedeslint-disable, local-only files in the commit range) and the boundaries lint. An implementer's report is rejected by a hook unless it names the files, the checks, and what it deferred. - Run reports. Every L run ends with a report: what merged, every ruling with what it costs if wrong, departures from the spec, open follow-ups, and a token ledger per agent run. The reports are what made this article possible. They are also what the next planner reads. The harness itself has its own write-up.
The ledger
| Run | Subagent tokens | Note |
|---|---|---|
| NgModule removal | 3.24 M | 38 agent runs; two reviewers wrong about injector lifetime, refuted by a throwaway spec |
| ESLint 9 + Nx standalone layout | 1.41 M | |
| Shared split and untangle | 2.88 M | the codemod and the 1,029-file move |
| Module boundaries | 0.92 M | |
| Waves 3a – 3e (five domain waves) | 10.64 M | three planners in one wave cost 1.03 M on their own |
| Shell and app move | 2.58 M | |
| Business slices | 2.83 M | 852 renames |
| CI on Nx | 3.14 M | 31 runs; the Jest spike alone 0.65 M over three rounds |
| Shared rework | 5.42 M | 52 runs, eight waves in one day |
| Total, subagents | ≈ 32 M | after removing rows ledgered twice; the orchestrating session's context is not counted |
Thirty-two million tokens over twelve days for a migration that touched 1,600 files. Whether that is cheap depends on what you compare it with; the comparison I use is the four-week estimate I would have given for doing it by hand with two engineers, and the fact that feature work did not stop.
What worked
- Mechanical gates caught the risk before any reviewer did. The NgModule run report says it in one line, and every later report confirmed it: type-check, the DI census spec, build parity and the boundaries lint found the real problems; reviewers found style.
- Review budget follows change shape. Per-task reviews yielded 0.3 code-changing findings per 100k tokens; the single whole-branch review at the end yielded 4.8. So the per-task reviewers now run only on a task that introduces a new shape, repeats of a shape get one batched pass per wave, and the whole-branch review runs once, scoped to the files that carry logic.
- Codemod plus manifest, one commit per domain. The implementer edits a manifest, the codemod does the rename, the build has to come out byte-equal. Agents are much better at writing a manifest than at hand-rewriting 300 import lines, and the diff is reviewable.
- Rulings with a price. Every decision in a run report carries "cost if wrong". It is a cheap discipline that made the two reversals in section 6 easy to make, because the cost of the original rule had been written down when it was made.
What did not
- Planners were expensive: 300 – 480k tokens per plan at first, because the planner had no edit tool and rewrote the whole plan on each revision. Fixed by giving it one; still the single most expensive role per run.
- Verification output bloat. The spec type-check prints 104 pre-existing errors on every run. Agents read all of it, every time, until the verification script compared against a baseline file and printed only the delta.
- Reviewers were confidently wrong twice: on route injector lifetime (two reviewers agreed with each other and with no evidence), and with a suggestion that would have been a library-to-app import. A reviewer's finding is a claim to verify, not a change to apply.
- Plans missed what no grep shows. Implementers blocked on the Sass partial, on barrel lines a codemod overwrote, on chunk bands. Each became a guard or a planner checklist line; none was predicted.
- Context runs out. Sessions were cleared at about 80 % and resumed from the ledger. The ledger has to be current for that to work, which is one more reason to keep it.
11. Results, and what is still open
| Measure | Before | After |
|---|---|---|
| Nx projects / libraries | 1 / 0 | 44 / 42 |
Domain folders in src/app | 18 | 0 |
@NgModule files | 28 | 0 |
| Cross-scope / upward / two-way import edges | 16 / 10 / 10 | 0 / 0 / 0 |
| Ignored cycles in the boundaries config | n/a | 0 |
| Jest projects · tests | 1 · 4,431 | 37 · 4,642 |
| Jest in CI, single-domain PR | 117.8 s | 21.2 s |
| Jest in CI, docs-only PR | 117.8 s | 0 s |
| Boundaries gate in CI | none | ~4 s, required |
| Initial bundle (Docker config) | 6.01 MB · 53 chunks | 6.25 MB · 42 chunks |
| ESLint errors (still not gated) | 288 | 280 |
| Lines of coverage hit | 18,998 | 19,002 |
Still open, in the order we would do them:
- Three timed PRs against the 11.3-minute median. The spike numbers are from the container; the pipeline number is the one that counts.
sideEffects: falseon the shared libraries, with its own QA click-through, for the 4 % back and then some.- The lint burn-down. 280 errors is parity, not clean. Until it is clean,
nx affected -t lintcannot be a gate, and the per-file parity check in the verification script does the job locally. - Remote cache, revisited with three months of affected numbers in hand.
- A short feature summary per scope: purpose, journeys, screens and routes, libraries, state, dependencies. Written on the follow-up branch, updated with every structural change, so the next person (or agent) reads nine short files instead of the graph.
12. If we did it again
- Boundaries before moves. Put the rule in with the first library and let the waves run against it. Every wave that passed the boundaries lint was a wave nobody had to think about.
- Start with the relaxed scope rule. "Only feature and shell are scope-private" is where we ended, and the detour through "lift everything into shared" cost a 28-commit PR. The ladder of types is the thing that prevents coupling; scope privacy on data and UI libraries only prevents reuse.
- Make testing a type on day one. Generators in a folder inside a production library will leak into the production compile the first time someone widens an ignore.
- Zero ignored cycles, from the first config. An allowance is a cycle with a comment. The four patterns are enough.
- Measure the Jest split before you commit it, in the CI container, on the real PR mix. The answer is not obviously yes, and round two of ours lost coverage of 91 files without a single red test.
- App moves last, NgModules go first. Both are about the number of open branches you collide with.
- Write the guard with the rule. If you cannot name the spec or lint rule that fails when the rule is broken, you have a guideline, and guidelines do not survive the third wave.
- Keep the ledger. Rulings with a price, token counts per run, departures from the spec. It is how the migration survived context limits and session handovers, and it is how this article got its numbers.
One PR per phase, nothing changes on screen, the build comes out identical, and a rule is a rule
only if something fails. The rest is git mv.