I used AI agents to migrate 44 Angular components in SAP Spartacus from Reactive Forms to Signal Forms.
On the first run, 34 looked successful.
The review phase showed that "successful" did not mean what I thought it meant.
AI scales transformation. It does not guarantee equivalence.
This article covers the initial migration run, what the follow-up review exposed, and how I would structure a large AI-assisted refactoring in a real client project today.
The Migration Target: Signal Forms Stage 1
Angular 21.2 ships SignalFormControl — a bridge between Reactive Forms and Signal Forms. Manfred Steyer's blog post describes the interop pattern: replace individual FormControl instances with SignalFormControl, keep FormGroup and templates largely intact, swap Validators.* for signal-based validators.
I call this Stage 1: a drop-in replacement with minimal blast radius. No full template rewrite. No FormArray migration (there's no SignalFormArray yet).
// Before
export class MyComponent {
form: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder) {}
ngOnInit() {
this.form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required]],
});
}
}
// After
import { SignalFormControl } from '@angular/forms/signals/compat';
import { required, email } from '@angular/forms/signals';
export class MyComponent {
protected readonly emailControl = new SignalFormControl('', (path) => {
required(path);
email(path);
});
protected readonly passwordControl = new SignalFormControl('', (path) => {
required(path);
});
form = new FormGroup({
email: this.emailControl,
password: this.passwordControl,
});
}
No more FormBuilder injection. No more ngOnInit initialization. Validators live in a schema function. Templates keep working because SignalFormControl extends AbstractControl.
Independent components, repetitive steps, an existing test suite — this looked like a near-perfect fit for agentic refactoring. That was true for the transformation itself. It was not true for validation.
The First Two Manual Migrations
I migrated CartCouponComponent and CartQuickOrderFormComponent by hand. Simple forms, straightforward validators. But even on these, I hit an edge case that became one of the most important migration rules:
The required HTML attribute trap. If your template has:
<input required="true" formControlName="email">
Angular's built-in RequiredValidator directive activates automatically and calls setValidators() on the bound control. SignalFormControl does not support dynamic validator mutation and throws:
NG01920: Dynamically adding and removing validators is not supported in signal forms.
The fix: remove the required attribute from the template — the validator already lives in the SignalFormControl schema. Even a migration that looks like a drop-in replacement has hidden edge cases.
After those two manual migrations, I extracted a reusable process and wrote it down.
The Three Artifacts
The entire orchestration rested on three markdown files:
goal.md — The Orchestration Protocol. Startup sequence, branch strategy, sub-agent loop, abort criteria. When to spawn, when to merge, when to give up.
SignalMigration.md — The Playbook. Step-by-step migration rules, validator mapping, import paths, special cases, verification commands. This was not "prompt engineering" — it was a technical playbook written the way I would document the task for another developer on a team.
Plan.md — The Bill of Materials. All 44 target components with Nx library, file paths, and status. The orchestrator used it as a state machine: TODO → IN_PROGRESS → SUCCESS / FAILED / SKIP.
The Orchestrator Architecture
The orchestrator was a Claude Code agent. It read goal.md, followed the protocol, and spawned one sub-agent per component using isolated git worktrees:
Agent({
subagent_type: "general-purpose",
isolation: "worktree",
prompt: `
You are migrating CheckoutLoginComponent from Reactive Forms
to SignalFormControl.
Read first: /SignalFormMigration/SignalMigration.md
Files to migrate:
- feature-libs/checkout/base/components/checkout-login/
checkout-login.component.ts
Nx library for tests: @spartacus/checkout/base
After migration, run the verification build.
Report: SUCCESS with commit hash, or FAILURE with error description.
`
})
The isolation: "worktree" parameter was critical. Each sub-agent got its own copy of the repository, branched from feature/signal-forms-migration. It could change files, run tests, and commit without interfering with other migrations.
On success, the orchestrator merged the worktree branch back into the feature branch using --no-ff. On failure, the worktree was discarded and the failure documented.
In total, the migration PR ended up with 94 commits. The initial pipeline ran in a single evening — roughly two to three hours of agent time.
Initial Results
Out of 44 target components:
- 34 completed the initial migration pipeline and were merged automatically
- 5 failed during migration
-
5 were skipped because they used
FormArray, which has no Stage 1 equivalent yet
That is a 77% initial automation rate across all 44 targets (34/44). If you exclude the 5 components that were intentionally skipped because FormArray has no compatible Stage 1 migration path, the initial run reached 87% across attempted components (34/39).
That initial result was real and useful. It proved that the mechanical part of the migration could be scaled across a large Angular codebase.
But the review phase changed how I interpret those numbers.
In the first pipeline, "SUCCESS" meant: the migration completed and no immediate TypeScript-level blocker remained. It did not reliably mean that unit tests had run, that runtime behavior was unchanged, or that validation semantics were preserved.
The Failure Taxonomy from the Initial Run
The 5 explicit failures were already interesting because they clustered around one API boundary: SignalFormControl does not support imperative validator or error mutation.
Failure 1: CsagentLoginFormComponent — The template had required="true" on inputs. Angular's RequiredValidator directive called setValidators(), which triggered NG01920.
Failure 2: OrderGuestRegisterFormComponent — Used CustomFormValidators.passwordsMustMatch, a cross-field validator that called setErrors() on another control.
Failures 3 & 4: DeliveryModeDatePickerComponent and DateRangeModalComponent — Both routed controls through a shared date picker component using [formControl]. Internally, Angular's form setup path called setValidators().
Failure 5: VerifyRegisterVerificationTokenFormComponent — A combination of setErrors() in error handlers, form.enable() in tests, and cross-field validators using imperative patterns.
These failures were useful because they revealed a consistent incompatibility pattern: code that reaches into the control and mutates validation or error state imperatively does not map cleanly to Signal Forms.
That alone would already have made for a decent migration story. But the later review surfaced a more important lesson.
What the Review Changed
I ran an adversarial code review against the migration diff, and it found problems that the initial pipeline had missed entirely.
Example 1: Tests never ran. In several worktrees, npm install had not been executed. The @types/jasmine package was missing, so nx test could not run at all. The sub-agents noted this as a warning — and then reported SUCCESS anyway, because the TypeScript compiler showed no errors. This meant that a significant number of "successful" migrations were never actually test-verified.
Example 2: Silently changed email validation semantics. In AsmCreateCustomerFormComponent, the agent replaced Spartacus's CustomFormValidators.emailValidator with Angular's built-in signal email() validator. But these use different regular expressions. The Spartacus validator accepts addresses like email@[123.123.123.123] and rejects email@example — Angular's does the opposite. The migration silently changed which email addresses the form accepts, with no test catching the difference.
Example 3: Validator side effects triggered at wrong time. In AsmBindCartComponent, a custom validator contained a side effect (resetDeeplinkCart()) that cleared UI state. After migration to SignalFormControl, the timing of validator execution changed. The review found that this could reset a deeplink alert immediately after it was set — a regression invisible in unit tests.
These are not mechanical errors. They are semantic changes that require domain knowledge to detect. No import check or template scan would have caught them.
The honest conclusion: automated transformation is not the same as validated correctness.
What I Would Do Differently Today
If I were running this in a real client project, I would use the same core idea — playbook-driven agentic migration — but change the process significantly.
Migrate in waves of five. Instead of pushing 44 targets through one autonomous pipeline, group them into waves of 4–5 components. Mix each wave deliberately: 2 simple components alongside 2–3 with known edge cases like template required attributes or custom validators.
After each wave, the orchestrator writes a summary and stops. The human reviews the results, decides whether to update the playbook, and approves the next wave. This is a hard constraint in the orchestration protocol. Agents that are allowed to cross wave boundaries without human approval will repeat systematic mistakes across dozens of components before anyone notices.
Keep the worktrees alive. Do not discard worktrees after the agent reports success. The worktree is the crime scene. Keep it around so you can inspect the diff, run tests manually, and trace what the agent actually did versus what it claimed.
Lock unit tests before and after. Run the full test suite before the migration as a baseline. Run it again after. If tests cannot run at all — missing dependencies, broken setup — the migration gets status ABORT, not SUCCESS. Green tests confirm syntactic correctness. They say nothing about semantic equivalence. That is where review starts.
Enforce hard constraints outside the prompt. The orchestration protocol limits each sub-agent to three test runs after the migration. I enforced this through the prompt. Some agents ignored it. In a production workflow, I would wrap the agent invocation in a deterministic harness — a script that counts test executions externally and terminates the process after the limit. Prompting is a request. A wrapper script is a mechanism. Any constraint that the agent must not violate belongs outside the agent's control.
Use a second model for adversarial review. A different frontier model reading the migration diff with adversarial intent catches a different class of errors than the model that wrote the code. Tools like codex-plugin-cc or Windsurf's Codemaps can help here — the principle is what matters: structural overview and adversarial challenge from a separate perspective.
In this migration, the adversarial review flagged two real issues the pipeline had missed: a validator side effect that could reset active-cart deeplink state on every template subscription, and an email validator replacement that silently changed which addresses the form accepts. Both were invisible to unit tests. Both would have shipped.
Human review closes the loop. After the adversarial model review, a senior developer reviews the wave. The human decides whether a semantic difference is acceptable, whether a validator change matches business intent, and whether the migration is truly done.
The resulting workflow per wave:
- Agent transforms 4–5 components in isolated worktrees
- Unit tests gate syntactic correctness
- Second model challenges semantic equivalence
- Human reviews and decides
- Update the playbook with new edge cases
- Next wave
The Real Takeaway
At scale, the problem is not transformation. The problem is verification.
The honest result of this experiment: agentic migration works — but only inside a structured process with explicit quality gates. Run it in small waves. Keep the evidence. Let the agent do the mechanical work. Let a second model challenge it. Let a human decide.
Agent transforms. Second model challenges. Human decides.
That is the workflow. Not "fully autonomous migration." Not "human reviews everything manually." A layered process where each step catches what the previous one cannot.
Context engineering > prompt engineering. The hard part was never writing a clever prompt. It was doing the first migrations manually, extracting the right rules, documenting the edge cases, deciding what counts as "done," and designing a process where speed and trust are not in conflict.
The AI did not invent the strategy. A senior developer defined the migration model, the playbook, and the quality gates. The AI scaled the repetitive part. The review caught what scaling missed.
That is where the real leverage is: not asking an agent to "do the migration," but designing a process that makes automation fast, inspectable, and safe.
This migration builds on Manfred Steyer's blog post on SignalFormControl interop. The initial migration run is at github.com/lutzleonhardt/spartacus/pull/1. The refined wave-based orchestration protocol and migration logs are on the signal-forms-migration-v2 branch, including the full orchestration protocol.
Top comments (0)