$ cat articles/nx-migration.md▊

Migrating a production Angular app to Nx in twelve days

One src/app with 18 domain folders, 28 NgModules and a shared folder that everybody imported became 42 tagged libraries behind an ESLint-enforced boundary. Feature work kept merging the whole time, and AI agents did the moves. This is a case study: the challenges we hit, how we solved each one, the two rules we reversed, and the numbers.

Andrii Siuta · October 2026 · ~25 min read Angular 21 Nx 23 ESLint 9 Jest 30 Claude Code
18 → 0domain folders in src/app
0 → 42tagged libraries
28 → 0NgModules
16 → 0cross-scope import edges
118 s → 21 sJest on a single-domain PR
10 PRs17 – 29 September

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:

MeasureValueNote
Cross-scope production imports16one domain reaching into another's folder
Upward imports from shared10shared importing four domains
Two-way edges10three domains importing each other in a ring, shared importing its own consumers
@NgModule files2810 lazy feature modules, 10 routing modules, 8 wrappers
Services provided only by a module327 features lazy-loaded at two or three paths each
ESLint errors / warnings288 / 4,576ESLint 8, not gated in CI
PR pipeline, median11.3 minJest 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.

MergedPhaseWhat changed
18 Sep−1 · NgModules out28 modules → route-scoped providers; 57 specs rewritten; a spec guards every bare @Injectable()
23 Sep0 · ESLint 9 flat configangular-eslint 21, typescript-eslint 8; error count pinned at 288 before and after
24 Sep1 · Nx, zero file movesnx init, angular.json → project.json, the shared testing library as the first library; build output byte-identical, second build a cache hit
24 Sep2 · Shared splitsrc/app/shared → five libraries; a TypeScript-API codemod rewrote the imports; cross-scope edges 16 → 0
24 Sep2 · Boundaries@nx/enforce-module-boundaries with the tag table, runnable as one npm script
25 Sep2 · Sonar on libsthe pipeline template reads coverage for libs/; lcov files merged
25 Sep3a · Leaf domainsthe four domains that import only shared: 6 libraries, 229 renames
28 Sep3b–3e + 4 · Domains and the app movethe 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 Sep5 · CI on Nxboundaries gate in the PR pipeline; 37 Jest projects; nx affected; summing lcov merge
29 Sep6 · Shared reworkdomain 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 shared can 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 nx commands; 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

challenge

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.

how we solved it

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.

challenge

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.

how we solved it

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.

cost

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.

rejected

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/.
type:app · the application type:shell · 1 library type:feature · 13 libraries type:ui · 10 libraries type:data-access · 11 libraries type:util · 1 library type:testing · 6 libraries anything import() only · lazy + data-access, util + ui, util + data-access ✕ never another feature *.spec.ts only imports data-access, util for its types scope-private: shell, feature. cross-scope: ui, data-access, util, testing. shared imports only shared.
Layers point down. A feature reaches 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-access or ui library. 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 a feature plus a ui library. 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 a ui library and the first domain data-access library in the workspace.
  • type:shell exists because the rule checks import() too. The shell lazy-loads every feature. Tagged type:feature it would fail at every loadChildren site, so it gets its own row that may reach features, and nothing else may.
  • type:testing is spec-only. Test generators live in libs/<scope>/testing; a no-restricted-imports block 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.
  • shared imports only shared. 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 an eslint-disable comment 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

challenge

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.

how we solved 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

challenge

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.

how we solved 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

challenge

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.

how we solved it

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

challenge

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.

how we solved it

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

challenge

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.

how we solved it

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

challenge

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.

how we solved it

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

challenge

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.

how we solved it

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"

challenge

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 wavesValue
Library source files in libs/shared442 of 1,631
Share of test suites affected by a shared/util change99 %
Methods on the shared façade24
Domains without a data-access library3 of 7

A shared that owns the domain models makes nx affected worthless, because everything is affected.

how we solved it

The last PR of the migration changed the rule:

Only feature and shell libraries are scope-private. A scope reaches another scope's data-access, ui, util and testing directly.

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:

  1. Layers point down only.
  2. A <scope>/testing library depends on its scope's data-access for types, never the reverse.
  3. A type that names two domains lives in the downstream library, the one that already depends on the other.
  4. Two features that need each other's screens share a ui library, 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

challenge

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.

how we solved it

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 feature library has no top-level folder from a list of twelve technical names or seven FSD segment names;
  • a data-access library has only store/ services/ models/ consts/ utils/;
  • a shared/* library imports no domain alias, including inside jest.mock strings;
  • 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

challenge

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.

how we solved it

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 shapeOne root project37 projects, affectedProjects run
Full suite (main)117.8 s175.0 s37
Single domain library117.8 s21.2 s3: the library, the shell, the app
shared/ui change117.8 s154.8 s23
shared/util change117.8 s173.7 s36
Docs only117.8 s0 s0, exits 0
Weighted by the real PR mix117.8 s≈ 85 s39 % 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.

challenge

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.

how we solved it

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.

GuardFails whenThe 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 codefeature-to-feature coupling, upward imports, a lazy chunk in the initial bundle
The library-shape speca feature library grows a technical folder; shared imports a domain; the library count dropsthe old folder layout coming back one folder at a time
The DI census speca bare @Injectable() is in no providers arraya NullInjectorError on one screen, after the module injectors went
The Sass-edge speca @use resolves into the app or into a feature librarya style dependency the project graph cannot see
The Jest-config-shape speca Jest config names a different project than its foldera mis-rooted project passing with passWithNoTests
Injection tokens for environment and runtime configa 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 PRthe 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 main carries 288), a conventions pass (translation-key parity, added comments, added eslint-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

RunSubagent tokensNote
NgModule removal3.24 M38 agent runs; two reviewers wrong about injector lifetime, refuted by a throwaway spec
ESLint 9 + Nx standalone layout1.41 M
Shared split and untangle2.88 Mthe codemod and the 1,029-file move
Module boundaries0.92 M
Waves 3a – 3e (five domain waves)10.64 Mthree planners in one wave cost 1.03 M on their own
Shell and app move2.58 M
Business slices2.83 M852 renames
CI on Nx3.14 M31 runs; the Jest spike alone 0.65 M over three rounds
Shared rework5.42 M52 runs, eight waves in one day
Total, subagents≈ 32 Mafter 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

MeasureBeforeAfter
Nx projects / libraries1 / 044 / 42
Domain folders in src/app180
@NgModule files280
Cross-scope / upward / two-way import edges16 / 10 / 100 / 0 / 0
Ignored cycles in the boundaries confign/a0
Jest projects · tests1 · 4,43137 · 4,642
Jest in CI, single-domain PR117.8 s21.2 s
Jest in CI, docs-only PR117.8 s0 s
Boundaries gate in CInone~4 s, required
Initial bundle (Docker config)6.01 MB · 53 chunks6.25 MB · 42 chunks
ESLint errors (still not gated)288280
Lines of coverage hit18,99819,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: false on 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 lint cannot 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.