Every long-lived ServiceNow instance eventually has The Custom App. The one that started as a small request management thing for one team and now owns 14 tables, 80 business rules, and a half-dozen integrations no one fully remembers wiring.
Here’s the playbook I follow when leadership asks for a “modernization” without a maintenance window.
What to extract first: the integrations
Integrations are the easiest thing to move out cleanly because they have a contract. Extract them into their own scoped app with a clear name (acme_billing_integration), expose a small API surface from the legacy app to call into it, and you’ve already done 30% of the decomposition with low risk.
If the integration was talking directly to the database tables of the legacy app, that’s the next problem to fix — but at least you’ve contained it.
What to leave alone: the UI
Resist the urge to refactor the UI in the same project. It’s the part most visible to users, the part most loaded with tribal knowledge, and the part where every change is a change-management conversation. Move the data and logic first; the UI can follow on its own track.
The boring middle: tables
Splitting tables is where most migrations either succeed or stall. The pattern that works for me:
- Stand up the new table in the new scope with the same fields.
- Dual-write from the legacy app’s business rules during a transition window.
- Backfill in batches at off-hours, with idempotent upserts.
- Switch reads behind a feature flag, table by view by view.
- Remove the dual-write only after the legacy reads are gone.
It feels slow because it is. It also doesn’t break production.
The feature flag I always stage behind
Every step above goes behind a single sys_property: acme.migration.<table>.read_target with values legacy, dual, new. One property, one switch per table, one thing to flip back if something is wrong.
The first time I tried to do a migration without that flag I rolled back at 3 a.m. by reverting an update set. The second time I had the flag, the rollback was a single property change. Different night.
Things I’d never do again
- Migrate the table and the UI in the same release.
- Skip the dual-write phase because “we’ll do a one-shot cutover this weekend”.
- Trust that a copy of an ACL on the new table is equivalent to the old one. They’re never equivalent. Test them both as a user.
When to stop
Decomposition isn’t a destination. The point isn’t to end up with twelve perfectly carved scoped apps. The point is to make the next change to this app safer than the last one. If you can ship a feature in the new structure faster than you could in the old one, you’re done. Anything more is a vanity project.