Demote, don't delete: the migration plan that survives production
Ask an agent to migrate a legacy Angular module and it opens with "delete". That plan is wrong. Here is the one that works, and the review that catches it.

The previous part gave the agent a task file for a new feature. A migration is harder, because the code is already in production. Ask an to modernise a legacy module and it answers with a plan in thirty seconds.
In the runs I have seen, that plan opens with "delete". Here is why it fails, the plan that works, and the five-minute review that tells them apart.
The agent's plan, and the three places it breaks
The concrete case is the demo repo's libs/legacy/transfers: an NgModule, a component with *ngIf, manual subscriptions, markForCheck() calls and its own table of transfer limits. The modern side of the app already has a store, AccountsStore, and the rules in libs/domain.
Say "migrate this to signals", and the typical plan has four steps (the workshop exercise checks for the same ones): delete LegacyTransfersModule, convert the component in place, write a validateTransfer() helper, and update the tests. It sounds clean. It is a big bang, and it breaks in three places.
Readers you do not know about. In an old codebase the legacy code has users the agent did not grep: a route, a link on another page, a guard. Delete the source and you meet them in production.
No moment to stop and ship. Converting in place means the old version is gone before the new one works. Halfway through, nothing is shippable, and the only way back is a revert.
An unreviewable diff. Nobody reads a migration done in one pull request. Review becomes "tests pass, ship it", and the tests were rewritten by the same agent that wrote the code.
The tell is in the first line.
Any migration plan that opens with "delete X" is wrong.
Demote, don't delete
The plan that survives has three steps, and each one can ship on its own.
Build beside. The new feature goes into its own lib, libs/feature/transfers, next to the legacy one. The legacy module keeps serving the page, untouched.
Flip the route. When the new form is proven, one line in app.routes.ts changes. The legacy module is now demoted: still in the tree, serving nobody.
# apps/atlas/src/app/app.routes.ts: the whole switch-over
- { path: 'transfers', loadChildren: () => import('@atlas/legacy-transfers').then((m) => m.LegacyTransfersModule) },
+ { path: 'transfers', loadChildren: () => import('@atlas/feature-transfers').then((m) => m.transfersRoutes) },If something is wrong in production, flipping back is the same one-line diff.
Delete last. When nothing imports the legacy lib any more, deleting it removes code and changes nothing. In the demo repo the allow only the app to import a type:legacy lib, so no modern library can lean on it. After the delete, a green typecheck proves the app no longer does either. Delete before the flip and the compiler stops you at once:
error TS2307: Cannot find module '@atlas/legacy-transfers' or its corresponding type declarations.
That is the only kind of delete you want: one the compiler checks for you.
Ask for the plan first
Do not ask for code. Ask for the plan, and read it against the rules:
Read AGENTS.md and every file under libs/legacy/transfers/src. Propose a plan to migrate the legacy transfers module to a standalone, signals-based feature that fits this workspace. Plan only: numbered steps, which files are created, changed or deleted at each step, and at which step the route /transfers/new/:accountId switches over. Do not write code and do not edit any file.
When the four steps from above come back, the review is short:
- Delete the module first: no. The legacy module keeps serving
/transfers/new/:accountIduntil the new feature is proven. It goes last, in its own change. - Convert in place: no. Build beside, in
libs/feature/transfers, so there is always something to ship. - A
validateTransfer()helper: it duplicates rules that already exist. The rules file says greplibs/domainbefore adding a magic value, andcanTransfer()and its messages are there. - Update tests: no. Existing assertions are never edited to make a diff pass. If one is wrong, the agent says so in the pull request and stops.
Then three task files instead of one pull request, each with its own size limit: pin the behaviour of the legacy code (task 000), build the new feature beside it and flip the route (task 002), delete the legacy lib (task 003).
With those task files, the component the agent builds looks like the rest of the codebase. In the demo repo's reference it holds only signals and computeds, and the rules come from one domain call, canTransfer(), which already had tests. No subscriptions, no markForCheck(), no limits table.
The task file also asks the agent to report the one real behaviour change. The legacy table ignored the transfers already made today; canTransfer() counts them. The duplicated table was hiding a bug, and the pull request says so instead of slipping it in.
On Monday
- A migration plan that starts with "delete": send it back.
- Build beside, flip the route, delete last.
- Make the agent propose before it codes, and read the plan against your rules. Five minutes.
Exercise: module 2 in the demo repo, scenes 1 and 2, with the frozen libs/legacy/transfers module and the task files. Start: git switch -c m2/you main. Reference: origin/track1/m2-ngmodule-solution.
The first of those task files, task 000, comes before any new code: the next part pins what the legacy code does today.
Track 1, part 4 of the free series behind the Fast and safe: AI in your Angular codebase workshop.
Fast and safe: AI in your Angular codebase
Let agents write Angular under rules, modernise legacy code without breaking production, and keep it secure, in three days, on an enterprise-shaped codebase.
See the agenda