Field Operations Industry, chapter 25 of 26
Migrating Off a Legacy System
Replacing a field service system in place: regional cutovers, account states, deterministic IDs, payment token imports, silent data and fix-forward patches.
Most field operations platforms aren't built on an empty field. They replace a field service management suite the business has run on for years: one product that holds the customers, the agreements, the schedules, the routes, the chemical records the state requires, and the card numbers the gateway charges every month. Leaving it means moving every customer, every future appointment and every stored payment method into the new platform while technicians are on the road six days a week, without a customer noticing anything except, eventually, a better portal. This chapter is how to do that: the order to move things in, the states that make a half-migrated business safe, and why you fix forward instead of rolling back.
Why It's Hard#
A legacy suite is the business's memory, and most of that memory has a consequence if it's lost or duplicated:
- Agreements and schedules decide what each customer is owed and when. Lose one and a customer goes unserviced; duplicate one and a technician shows up twice.
- Payment methods live as tokens in the legacy gateway's vault, readable only by that gateway. Lose one and the customer has to be asked for their card again; duplicate a subscription and they're charged twice.
- Application records (what was applied, where, at what rate) are records states require applicators to keep (Regulated Materials). They have to stay retrievable even if they never enter the new system.
- The business doesn't stop. There's no maintenance window long enough to move everyone at once, and the busy season is the worst time to try. Plan the bulk of the migration for the slow season of your trade: winter for pest control and lawn care, the mild months between heating and cooling for HVAC.
Domain by Domain, or Region by Region#
There are two ways to cut the problem, and you'll use both. Martin Fowler's strangler fig pattern describes the general idea: build the new system alongside the old one and move behavior across gradually, rather than replacing everything at once.
- Domain by domain moves one capability at a time while the legacy system stays the source of truth for the rest. It works well for domains that don't need to own customer records: licensing and compliance, the sales team's canvassing and recruiting, workforce and HR integrations, internal tooling. It works badly for the core, because a customer whose schedule is in one system and whose billing is in another needs a two-way sync between them, and two-way syncs between a system you control and one you don't are where data quietly diverges.
- Region by region moves every domain for a set of branches at once. Branches operate independently enough that this is natural: a customer belongs to one branch, technicians mostly work in one, and the legacy system usually files everything under an office that maps to a branch. Each branch is then entirely on one system or the other, and nothing needs to stay in sync across the line.
The combination that works: move the domains that don't need customer ownership first, then cut customers over region by region with every customer-facing domain at once. Start with a pilot of one or two branches that are small, stable and willing, then go in waves of regions, with a week or two between waves to absorb what each one taught you. An illustrative plan:
| Wave | Branches | Purpose |
|---|---|---|
| Pilot | Two small branches in different states | Prove the process end to end, including billing and state records |
| Wave 1 | One region | Prove it at the scale of a region, with a full support team watching |
| Waves 2–5 | One or two regions each, weekly | Repeat, with the runbook stable |
| Final | The largest or most complex regions | Run last, with the most practice behind you |
One rule prevents a class of mistakes: a legacy office belongs to exactly one migration. Two waves that both claim a branch will both try to move its customers.
Reference Data First#
Before any customer moves, the reference data they point to has to exist: regions, branches and their areas, users, products and plans, materials and their label data, pests, target areas, cancellation reasons. This global data moves first, and it moves through an explicit plan:
- Pull each legacy office's lists into a staging store, untouched.
- Plan the mapping: which legacy records become which new ones. Offices that maintained their own lists for years will have the same product, pest or reason entered dozens of times with different spellings, and the plan collapses them: a list of thousands of legacy entries can become a hundred or so real ones. People who know the business review the plan before it's applied; the merge decisions are business decisions.
- Apply the plan, recording the legacy-to-new mapping for every record.
A customer migration for a branch waits until the branch's global data is applied. Make that a state, not a convention: a migration run sits in waiting for reference data until its prerequisites report done.
Deterministic IDs#
Every record you create from legacy data gets an ID derived from the legacy one: a UUID version 5 computed from a namespace per record type and the legacy ID (UUID v5). RFC 9562 requires that name-based UUIDs generated from the same name in the same namespace are equal, at any time. That one property makes the rest of the migration tractable:
- Reruns are idempotent. Migrating the same customer twice produces the same IDs, so the second run updates rather than duplicates.
- References can be computed without lookups. An appointment's legacy subscription ID tells you its new subscription ID directly, even if the subscription hasn't been written yet, so records can be loaded in parallel and in any order.
- The mapping is never lost. Support, finance and the data team will need to go from a legacy ID to the new record for years. Keep the mapping table anyway, but deterministic IDs mean it can always be recomputed.
Records created after the migration use ordinary random IDs (UUID v7); the derived ones are only for things that came from the legacy system.
Customer Data: Purge and Rerun#
Customer data (accounts, people, contacts, subscriptions, appointments, invoices and payments, payment profiles, documents, notes, application records, alerts) moves per branch, and it moves differently from reference data: no planning stage, but a cheap way to start over. Because IDs are deterministic and nothing customer-facing has happened yet, the fastest fix for a bad transformation during the window is to purge the branch's migrated customer data and run it again with the corrected code. Keeping intermediate state to resume from is more complex and rarely faster.
Decide early what to move and what to leave:
- Move what's live. Active agreements and their schedules, future appointments, open invoices and balances, autopay methods, contacts and consent, alerts and notes, documents the customer signed.
- Move enough history to work. The last several services per customer, so technicians see what was done last time and reservices can be honored.
- Leave the rest reachable. Years of completed appointments and paid invoices don't need to become live records. Load them into the analytics warehouse, keep an archive of the legacy data, and make sure required records (application records, signed agreements) can be produced on request (Operating Metrics).
- Deduplicate customers across offices. The same household that moved between branches will exist twice in the legacy system. Match on normalized email, name and state, merge into one account, and keep both legacy IDs on the mapping.
Every Account Has a Migration State#
During a migration that takes months, the business has customers on both systems, and every domain has to know which is which. Give each account a migration state, owned by CRM and projected everywhere else:
- Not migrated. The legacy system owns the customer. Pre-create these accounts in the new platform early, so the customer can log into the new portal, which recognizes the state and redirects them to the legacy one.
- Migrating. The branch's data is being moved. Both systems treat the account as frozen: no new appointments, no edits, no charges.
- Payments linking. Customer data is in place; billing is waiting for the processor's imported payment tokens to attach to the right accounts. Separate this state, because it's where a migration is most likely to stall and where triage needs to look.
- Migrated. The new platform owns the customer for good.
The migration run itself has states too: scheduled, waiting for reference data, running, in manual triage, linking payments, completed. Each customer's progress within a run is logged (pending, in progress, completed, failed, skipped, aborted) so the team can see exactly which hundred customers out of fifty thousand need attention.
Payment Methods#
Payment methods are the hardest part, because you can't move them yourself. A card token from the legacy gateway is meaningless to anyone else, and the card numbers behind it can only be exported by the gateway, to another PCI-compliant provider (Processor Routing, Running a Card Vault). Stripe's import process is typical: the previous processor encrypts the data to Stripe's PGP key and uploads it over SFTP, Stripe sends a validation summary for approval, imports it, and returns a mapping file from the old IDs to the new ones. Stripe says imports usually complete within 10 business days of receiving valid data, and warns that the previous processor's export can take anywhere from days to months. Start that request first.
The import has to line up with your accounts, which is why payments need their own preparation:
- Premigrate a few days ahead. Before the cutover weekend, create the billing customers for the branch and a placeholder payment method record for each legacy token, carrying the legacy token's ID. When the processor's import lands, each imported method attaches to its placeholder through the mapping file or a webhook, with no guessing.
- Watch the invariant. A placeholder with a legacy token ID and no new token is a card that didn't come across. Query for it after every import, by branch, and chase the stragglers before their first billing date.
- Migrate what's used. Payment methods that pay recurring charges must come across. Methods a customer used once years ago may not be worth importing, and leaving them out shrinks the import and the cleanup. Customers who paid manually can add a method in the new portal.
- Don't charge before you're ready. A subscription created before its token exists starts by sending invoices instead of charging, and switches to automatic collection when the token attaches. Imported open invoices are created with automatic advancement off (Stripe's
auto_advance=false), which stops the processor from finalizing them, retrying them and emailing about them on its own schedule. - Keep billing dates continuous. A customer billed on the 15th stays on the 15th. Stripe's subscription migration guide makes the same point: set the billing cycle anchor and start date so a mid-cycle subscription continues where it was. And cancel the legacy subscription as each one goes live, or the customer is billed by both.
- Expect more declines at first. Stripe notes that issuers may not recognize a new merchant ID and decline more at first, and recommends ramping volume rather than switching everything at once. Keeping the legacy gateway available as a fallback for a declined charge, for a defined period, is a reasonable bridge.
Migrated Data Must Not Notify#
The new platform is event-driven, which means creating ten thousand appointments emits ten thousand appointment.created events, and every consumer that sends a reminder, a welcome email or a "your agreement is complete" message will happily send it (Platform Architecture). A customer who has been with the company for six years should not get a welcome email on a Saturday because their branch migrated.
- Mark migration writes. Every record and event created by the migration carries its source, and communications and automations skip anything from the migration source.
- Seed what customers expect, directly. Appointments far enough out still need their reminders. Create those reminders through the communications API with the right send times, not by replaying events, and at most one per channel per appointment.
- Tag everything seeded. Anything the migration creates in communications is tagged as legacy-seeded, so a mistake can be purged by tag without touching messages the platform sent on its own.
- Test it in a no-send environment. Non-production communications should record messages without sending them except to an allowlist, and a migration rehearsal should end with a count of what would have been sent. That count should be the reminders you seeded and nothing else.
The Cutover Weekend#
Each wave is a scheduled event with a runbook, staffed like an incident (Incident Response):
- Friday evening: freeze the branches in the legacy system, take the final pull, and move the accounts to migrating.
- Saturday and Sunday: apply, triage failures, purge and rerun where needed, link payments, reconcile.
- Monday morning: technicians open the new app and their routes are there.
The runbook makes the weekend boring:
- Shifts with named owners. People work in fixed shifts through the weekend, and each shift has an owner who can make the call. A schedule published in advance is part of the plan.
- A checklist in version control. Each step is checked off in a commit as it's done, which makes the checklist the audit trail of what happened when.
- Write APIs on only during the window. The migration's production endpoints are switched on at the start of the window and off at the end, through a deliberate, recorded action. A migration job accidentally pointed at production on a Tuesday should fail, not write.
- Go/no-go gates. Before linking payments and before opening the branch to the field, the owner reviews the reconciliation and decides. A no-go before payments link is cheap: purge, return the accounts to not migrated, unfreeze the legacy branch, and reschedule.
Fix Forward#
Once a migrated branch has run for a day, there is no real rollback. Customers have paid, technicians have completed services, appointments have been booked and messages sent, all in the new platform. Moving back would mean migrating that new data into the legacy system, which nobody has built or tested. So the plan has a clear line: before the go/no-go gate, roll back by purging and rerunning; after it, fix forward.
Fixing forward needs its own machinery, because migration bugs surface for weeks:
- Patches as versioned, idempotent jobs. Each correction (recalculating tax on migrated invoices, remapping a product, repairing cancellations that came across wrong, syncing names) is a named, versioned patch that can run against migrated accounts any number of times with the same result.
- Tracked per account. Record which patches, at which version, have run on each account. A customer migrated next month gets this month's patches automatically; an account that failed a patch can be retried alone.
- Two ways to run. A bulk runner for a whole branch, and a per-account runner for support to apply to one customer while they're on the phone.
Reconciling#
"It ran without errors" is not the same as "it's right." Reconcile every wave against the legacy system, by branch:
- Counts: accounts, active subscriptions, future appointments, open invoices, payment methods.
- Sums: open balances, recurring revenue, credits.
- Invariants: every active subscription has a schedule and a billing subscription; every autopay customer has an attached payment method; no account is still in migrating or payments linking after the weekend.
- Deltas: anything changed in the legacy system after the final pull, from a late payment to an office that didn't stop booking, gets caught and migrated in a follow-up run.
Track open migration issues as an operating number (opened, resolved, still open) until it reaches zero and stays there (Operating Metrics).
After the Last Region#
The migration ends when the legacy system can be turned off, which comes later than the last cutover. Keep read access until every required record is retrievable from the archive or the warehouse; confirm the legacy gateway has no subscriptions left to charge; remove legacy identifiers and headers from the platform's APIs once no client sends them; and know the legacy contract's end date well before you reach it.
What This Means for the System You Build#
- Region-by-region cutovers for customer data, after moving the domains that don't own customers, with a pilot and waves.
- Reference data planned, deduplicated and applied first, with the run waiting on it as an explicit state.
- UUID v5 IDs derived from legacy IDs, so reruns are idempotent and references computable.
- Purge and rerun as the in-window fix for customer data.
- A migration state on every account, honored by every domain, including a portal that redirects unmigrated customers.
- Payment tokens imported through the processor, with placeholders created days ahead, a missed-token invariant, invoices that don't advance on their own, and continuous billing dates.
- Migration writes that never notify, with seeded reminders and legacy-tagged messages.
- A versioned checklist, shifts, switchable write APIs and go/no-go gates for every cutover weekend.
- Idempotent, versioned patches tracked per account, runnable in bulk and one at a time.
- Reconciliation by counts, sums, invariants and deltas, and an issue count that reaches zero.
References#
Specifications
- RFC 9562: Universally Unique IDentifiers, name-based UUIDs from the same name and namespace must be equal (section 6.5)
Reference
- Martin Fowler: Strangler Fig, replacing a legacy system gradually by building alongside it
In practice
- Stripe: Import payment data, PGP-encrypted transfer from the previous processor, validation, a mapping file, typical timelines, and declines on a new merchant ID
- Stripe: Migrate subscriptions using the APIs, keeping billing cycle anchors continuous and starting subscriptions in the future
- Stripe: Automatic invoice advancement, what
auto_advance=falseturns off