Case studies

14 Majors in Two Days: Migrating a 150K-Line Angular 8 App to Angular 21 with Claude Fable 5

How Claude Fable 5 orchestrated Opus and Sonnet sub-agents through a direct Angular 8 to 21 migration of a production golf club management platform — planning artifacts, closed-loop Playwright verification, and a gotcha catalog for anyone attempting the same.

Mohan Kumar

Mohan Kumar

Author

Angular 8 to Angular 21 migration orchestrated by Claude Fable 5

A client of mine runs a golf club management platform — tee sheet booking, member billing, POS, the works. It had been running on Angular 8 since 2019. Nobody had touched the framework version since, because nobody wanted to. The app had grown to roughly 150,000 lines: 96K lines of TypeScript, 38K lines of templates, 17K lines of styles, spread across about 100 components and 22 NgModules. A tee-sheet calendar module alone was 21,000 lines of custom scheduling logic wired into three different grid and drag-drop libraries.

When I scoped what it would take to get this to a current Angular version the classical way — one major version at a time, ng update hop by hop, through 13 intermediate majors — the estimate came back at three to four months. That’s the kind of number that makes a migration quietly not happen for another few years.

It actually took about two days of working sessions, using Claude Fable 5 (Anthropic’s newest model, running in Claude Code) as an orchestrator directing Claude Opus and Claude Sonnet sub-agents. This post is not really about “AI wrote the code” — plenty of things can write Angular code. It’s about how the work was structured so that a migration this size didn’t collapse under its own coordination overhead, and about the very specific ways a codebase like this breaks when you drag it forward 14 major versions in one motion.

Why not just run ng update 13 times

The naive path is sequential: Angular 8 → 9 → 10 → … → 21, running the official migration schematics at each step, fixing whatever breaks, and repeating. It’s the “safe” path precisely because each hop is small. It’s also why it takes months — you pay the integration tax thirteen times instead of once, and at every hop you’re also dragging along whatever third-party libraries happen to support that intermediate version.

Before committing to either approach, Fable’s first job was analysis, not code. It produced a dependency compatibility matrix across the app’s third-party packages, and the matrix was not encouraging:

DependencyStatus
ng2-signalrDead project, no Angular 9+ support
ngx-contextmenuDead project, incompatible with modern Angular
ng-drag-dropDead project
ng-chatDead project, unmaintained, incompatible with modern Angular
@angular/flex-layoutOfficially end-of-life, deprecated by the Angular team itself
xlsxPinned to a version with a known CVE

None of these have a clean incremental upgrade path. Sequential ng update would still hit every one of these walls, just spread across thirteen separate stops instead of one.

Two other findings shaped the target version itself. First, PrimeNG — the UI library this app leans on heavily — went closed-source; its public repo is archived. That makes landing on PrimeNG’s last MIT-licensed line a real constraint, not a preference. Second, PrimeNG’s major versions now track Angular’s majors directly: primeng@22 peers against Angular 22, which means Angular 21 needs primeng@21, Angular 20 needs primeng@20, and so on. Miss the pairing by one and npm install fights you the entire way. Between the two, we landed on Angular 21, not the newest Angular 22 — 21 sits in Angular’s LTS window with a fully-formed ecosystem behind it, where 22 was still too fresh for some of the libraries this app depends on.

With the target picked, the second decision was the more consequential one: direct migration, not incremental. Scaffold a clean Angular 21 workspace from scratch, then port the existing code into it module by module — rather than dragging the Angular 8 workspace through thirteen schematic runs. “New shell, port the code,” was the working description. It sounds riskier on paper. In practice, it meant every dependency decision got made once, against the real target, instead of thirteen times against a moving one.

Planning artifacts as contracts

None of this got orchestrated from a single chat window’s worth of context. Before any porting started, Fable produced a set of planning documents and committed them into the repo — not as documentation for its own sake, but as the shared contract that later sub-agents would work against.

  • A migration plan with phase gates — the module order, and what “done” meant for each phase before the next could start.
  • A line-precise tee-sheet API worksheet. The 21K-line tee-sheet calendar leans on gridster2, ag-grid, and a context-menu fork, and library APIs shift silently across major versions. A read-only Sonnet agent was sent through the actual installed typings and catalogued every API delta against what the code called — including the dangerous class of failure where a method still exists, still compiles clean, and simply no-ops at runtime.
  • A template-migration cheat-sheet, generated from real usage counts rather than guesswork — e.g., flagging that p-tabView appeared in 52 files before anyone started rewriting it, so the scope was known up front instead of discovered file by file.
  • A QA regression checklist mined from the routing configuration — roughly 73 routed screens, each risk-tagged, giving the later verification pass a map instead of a blank slate.

The point of writing these down wasn’t ceremony. An orchestrator directing multiple sub-agents across a two-day window can’t hold the whole app in its head continuously, and neither can a sub-agent that only sees the slice of the app it was handed. The worksheets were the thing that let a sub-agent that had never seen the tee-sheet module still port it correctly against real, verified API deltas instead of assumptions.

The orchestrator / sub-agent pattern

This is the part of the migration I think is actually novel. Fable didn’t write all 150,000 lines itself. It ran the migration the way a competent tech lead runs a large refactor with a small team: keep the risky, architecturally sensitive work close, delegate the mechanical work in parallel, and never let anyone touch the build in an uncoordinated way.

RoleModelHandled
OrchestratorClaude Fable 5Planning, build ownership, cross-agent verification, final integration
Complex portsClaude Opus16-dialog windows module (shopping cart + payment tokenizers), dashboard shell, the 21K-line tee-sheet capstone, recovering a dead chat library’s original source from sourcemaps
Mechanical/parallel workClaude SonnetSettings/reports/inventory/management module ports, a 277-reference CSS class-name port, the browser verification sweep, and this blog post

A few orchestration rules made this actually work, rather than turning into three agents overwriting each other’s changes:

One build owner. Only the orchestrator ever ran ng build. Parallel Angular builds fight over the same build cache and produce results that are hard to attribute to any one change. Sub-agents doing edit-only work verified their own slice with throwaway, narrowly-scoped tsconfig type-checks instead of a full build — enough to catch type errors in their files without touching shared build state.

Hard file-scope boundaries. Each agent got an explicit set of files it owned. The “switchboard” files — the module files that re-enable each ported feature module as it comes online — were the one exception, owned exclusively by the orchestrator, because two agents independently toggling the same module registration is exactly the kind of conflict that’s invisible until it silently isn’t.

Reports, not trust. Every agent handoff came back as a written report of what changed and why — and the orchestrator independently re-verified the claims rather than taking them at face value. This mattered concretely: a green build was proven, more than once, not to mean working templates. With Angular’s relaxed template type-checking, a component whose selector had been accidentally removed still compiles clean; nothing in the build tells you the template silently stopped rendering it.

Resumable interruptions. Long-running agent sessions occasionally hit transient API errors. Rather than losing the work, interrupted agents were resumed from their own transcripts mid-task, picking back up where they’d left off.

Knowledge compounding. When the first agent to hit p-tabView worked out the forced rewrite to PrimeNG 21’s p-tabs family, that recipe went into the shared cheat-sheet immediately — so the next agent to hit one of the other 51 files applied the same pattern verbatim instead of re-deriving it.

Cross-checking. Agents caught each other’s mistakes. One agent’s sweep of Bootstrap class renames was later reverted by a different agent that recognized the classes in question — things like ml-15, pixel-spacing utilities — as app-defined lookalikes, not actual Bootstrap classes, despite the naming convention suggesting otherwise.

None of these rules are exotic. What made them matter is scale: without them, a 150K-line migration split across three agents working in parallel turns into a merge-conflict generator. With them, it turned into something closer to an assembly line with a foreman who double-checked every station.

Engineering highlights

A few pieces of this were genuinely interesting engineering, independent of the orchestration story.

A dead protocol wrapper, faithfully rebuilt. The backend runs classic ASP.NET SignalR on .NET Framework — not ASP.NET Core SignalR. The modern @microsoft/signalr client speaks a wire protocol that’s incompatible with the classic server. Changing the wire protocol was the risky option; instead, Fable vendored a faithful TypeScript port of the dead ng2-signalr wrapper sitting on top of the classic jQuery SignalR client, and aliased it into the new build via tsconfig path mapping:

{
  "compilerOptions": {
    "paths": {
      "ng2-signalr": ["./src/app/vendor/ng2-signalr"]
    }
  }
}

Every consumer import in the app stayed byte-identical. No call site changed; only what that import resolved to changed.

Resurrecting a chat library from its own sourcemaps. ng-chat was dead and unavailable in a form compatible with modern Angular, but the published npm package’s inline sourcemaps still carried the original TypeScript in sourcesContent. That source was extracted verbatim — templates and CSS byte-identical to the original — and dropped in as vendored code rather than reverse-engineered from the compiled output.

A global-script escape hatch for a legacy bundle. fabric.js 2.7’s npm entry point reaches for Node builtins that esbuild won’t shim automatically. Rather than patch fabric.js or fight the bundler, it got loaded as a plain global script tag, with a small window-backed shim aliased in as if it were the module — sidestepping the bundling problem entirely instead of solving it.

101 decorators, one script. Angular 21 defaults new components, directives, and pipes to standalone. This app is NgModule-based throughout, so every one of those decorators needed an explicit standalone: false. A script stamped all 101 of them rather than hand-editing.

The gotcha catalog

This is the part worth bookmarking if you ever attempt something similar. Every one of these compiled cleanly — or ran fine in dev — and broke in a way that only showed up when a human, or a browser session, actually looked.

Zoneless by default, even for NgModule apps. Angular 21 defaults new applications to zoneless change detection. Loading zone.js is not sufficient to opt back in — you have to explicitly provide the change detection strategy:

import { NgModule, provideZoneChangeDetection } from '@angular/core';

@NgModule({
  providers: [
    provideZoneChangeDetection(),
    // ...
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Without it, every state change driven by SignalR pushes, jQuery event handlers, or plain HTTP callbacks silently never reaches the view. The symptom that gave this away: the nav menu stayed hidden after a successful login. The login worked. The component’s model updated. The screen just never repainted.

PrimeNG now follows the OS dark scheme by default. PrimeNG’s newer themes ship a darkModeSelector that reacts to the OS-level color scheme preference — not an app setting. On a machine set to dark mode, this meant black inputs rendered on an otherwise light app, with no code change required to trigger it.

277 dead class-name references. PrimeNG’s ui-* CSS class naming convention was retired starting at v10. This app still had 277 references to those class names scattered through custom stylesheets, all silently pointing at classes that no longer exist on any rendered element. Components rendered structurally correct and completely unstyled.

p-tabView was removed outright in PrimeNG 21. Not deprecated — gone, replaced by the p-tabs family with a different API and different event handler naming. 58-plus tab panels across the app needed structural rewriting, not just a class rename:

<!-- before: PrimeNG 9 -->
<p-tabView (onChange)="onTabChange($event)">
  <p-tabPanel header="Bookings">...</p-tabPanel>
</p-tabView>

<!-- after: PrimeNG 21 -->
<p-tabs (valueChange)="onTabChange($event)">
  <p-tablist>
    <p-tab value="0">Bookings</p-tab>
  </p-tablist>
  <p-tabpanels>
    <p-tabpanel value="0">...</p-tabpanel>
  </p-tabpanels>
</p-tabs>

Google Fonts, quietly dropped. Fonts were loaded via @import url(...) inside SCSS. Webpack used to hoist those imports to the top of the compiled CSS bundle, which is where the CSS spec requires @import to live for the browser to honor it. esbuild — Angular’s newer build pipeline — leaves the import where it was written, mid-file, and browsers silently ignore an @import that isn’t at the top. The fonts just stopped loading, with no build warning. The visible symptom was oddly squished-looking headings, because the letter-spacing had been hand-tuned for a font that was no longer there.

ag-grid 36 removed gridOptions.api. Grid APIs now have to be captured from the (gridReady) event instead of read off the options object after the fact. Grids living inside route-reuse-cached components have a second-order version of this problem: a fresh grid instance hands you a new api reference on re-entry, and code holding onto the old reference silently talks to a dead grid.

angular-gridster2 v21 renamed its types and quietly no-oped one. One of the options-object API methods the tee-sheet calendar depended on for layout behavior became a silent no-op in the new major — no error, no deprecation warning, just layout logic that stopped doing anything.

The context-menu fork’s CDK overlay had no CSS underneath it. The maintained fork of the dead context-menu library positions its menus using Angular CDK overlays. Without the Material CDK’s prebuilt overlay stylesheet — which the old webpack build happened to pull in and the new build initially didn’t — menu items rendered scattered across the page in normal document flow instead of as a positioned popup. The fork also ships zero visual styling of its own (the old library had borrowed Bootstrap’s dropdown-menu look for free) and no Escape-key handler, both of which had to be rebuilt.

Bootstrap lookalikes that weren’t Bootstrap. Classes like custom-select, custom-control-*, and pixel-scale utilities such as ml-15/mr-15 look exactly like Bootstrap 4 classes due for the standard BS4→BS5 rename sweep. Several of them were actually app-defined utility classes that happened to share Bootstrap’s naming convention. A naive rename pass breaks them — which is exactly what happened once, and was caught by a later agent that checked before renaming further instances.

Testing with Playwright MCP

The build passing was never treated as evidence the app worked. The verification loop used Playwright MCP to drive a real browser against the migrated app talking to a live backend sandbox — logging in through the actual login form, navigating through the actual menus, not through test fixtures or mocked data.

Two capabilities did most of the work. browser_snapshot gave accessibility-tree snapshots of every screen state, cheap enough to take constantly. browser_evaluate — run against Angular’s dev-mode window.ng.getComponent() API — let the orchestrator reach into a live component instance and read its actual internal state, not just what the DOM happened to show.

That second capability is how the zoneless change-detection bug got diagnosed, and it’s worth walking through because it’s a good example of what this loop is actually for. The nav menu stayed hidden after login. A screenshot alone tells you “it’s hidden” — not why. browser_evaluate against the live component showed the model’s hidden flag had already flipped to false after login succeeded, while the DOM still carried the hidden attribute. Model state and rendered state had diverged — which is precisely the symptom of a view that stopped receiving change detection notifications. That single live-state read pointed straight at the missing provideZoneChangeDetection() call, instead of a long guessing session across event bindings and subscriptions. The same technique later confirmed a grid rendering issue: the component believed it had pushed 14 rows into the grid, while the grid’s actual api reported 0 columns configured — the stale-api-reference problem from route-reuse, confirmed from live state rather than inferred from a blank-looking table.

Once the migration work was in a stable state, a separate Sonnet agent ran a dedicated verification sweep across roughly 15 pages — exercising tabs, datepicker popups, right-click context menus, dialogs, and grids on each — and produced a defect report prioritized P1/P2/P3, each defect backed by a screenshot and the console error log captured at the time. The orchestrator worked through that list, fixed each defect, and re-verified in-browser rather than taking the fix on faith.

Not everything that looked like a defect was one. Some flagged issues turned out to be pre-existing behavior, confirmed by diffing the migrated template against the pre-migration branch and finding them byte-identical — the bug predated the migration and was never a migration artifact. One “invisible popover” turned out to be a popover rendering correctly over an empty SMS log — there was simply nothing to show.

The end state of the loop: a real login against the live backend, a working tee sheet showing 109 real bookings, and zero console errors on the pages exercised.

Outcomes

MetricResult
Commits26, each phase independently green
Production buildWorking, initial bundle ~1.9 MB compressed
Old build memory requirement20 GB workaround needed under webpack
New build memory requirementStandard — no workaround
Dev build timeSeconds
Modules enabledEvery module of the original app
VerificationBrowser-verified against a live backend, real login, real data
Elapsed time~2 days of working sessions
Classical estimate3-4 months

What this doesn’t replace

The honest caveat: this loop verified that the app renders and responds correctly across roughly 15 pages of normal interaction — tabs, dialogs, grids, context menus, navigation. It did not replace human QA of destructive flows. Bookings and payments — the two categories of action in this app where a bug costs someone real money or a double-booked tee time — were deliberately left for human testers to exercise before this went anywhere near production traffic. An AI-driven browser loop is very good at catching “this doesn’t render” and “this doesn’t match what the component thinks its state is.” It is not a substitute for a human deciding whether a payment flow is safe to trust with real transactions.

Takeaways if you’re staring at your own multi-major migration

A few things I’d carry into the next one of these:

  1. Analyze before you decide the path. The dependency compatibility matrix is what told us sequential ng update was going to hit the same dead-library walls regardless, thirteen times instead of once. That single finding is what justified the riskier-looking direct migration.
  2. Write the worksheets before the porting starts. The tee-sheet API delta worksheet and the template usage cheat-sheet weren’t busywork — they’re what let agents that had never seen a given module port it correctly against verified facts instead of assumptions.
  3. One agent owns the build, always. Everything else can parallelize. The build cannot, and pretending it can produces unattributable failures.
  4. A green build is not a working app. Angular’s relaxed template checking will let you compile right past a missing component selector. Only a browser, actually rendering the page, tells you the truth.
  5. Read live component state, not just the DOM. The zoneless diagnosis only took one browser_evaluate call because it compared what the component believed against what the screen showed. That comparison is where this class of bug always hides.
  6. Some things stay human. Speed on the migration mechanics buys you time to spend more carefully on the flows where a mistake actually costs money.

This migration was for a golf club management platform I do ongoing engineering work for — details anonymized. If you’re looking at your own long-overdue Angular upgrade and want to talk through the approach, reach out on X @smhnkmrtweet or check out the About page.

#claude #angular #migration #ai-development #case-study #playwright #orchestration