Every modernization proposal contains the same sentence: there will be no downtime. It is usually written before anyone has decided how two systems will share a database, or who finds out first when a migrated workflow returns the wrong totals. Continuous operation is not a commitment made at kickoff. It is a property of specific engineering practices, and when they are missing the promise breaks in month nine, in production.
Cut by business capability, not by technical layer
The first decision is where to cut, and most teams get it wrong. The instinct is to slice horizontally: extract the data access layer, then the service layer, then the UI. That produces months of work with nothing releasable, because a layer is not something a user can be switched onto, and half-migrated layers leave both systems entangled, the state you were trying to escape.
Cut vertically instead, along business capabilities. Work orders. Inventory. Customer records. Point of sale. Each slice owns its data, rules, screens, and integrations, so it can be built, tested, released to a subset of users, and rolled back without touching anything else. It also answers the question executives ask: not how much code has moved, but which parts of the business now run on the new platform.
- A cluster of tables that only one part of the application writes to
- A workflow with a clear start and end state that a user would name out loud
- A capability with few inbound calls from the rest of the system, even if it makes many outbound ones
- An area where the rules are stable and understood, so you are porting behavior rather than rediscovering it
- Something a distinct group of users touches, which makes rollout by location straightforward
Do not start with the most tangled, highest-traffic module on the theory that the hardest thing should go first. The first slice is where you build the migration machinery: deployment path, flag mechanism, observability, rollback. Build it where a mistake can be absorbed.
Coexistence is the strategy, not a transitional phase
The old and new systems run side by side for the whole program, often a year or more, both reading a shared source of truth. Users move capability by capability, sometimes location by location. Nobody experiences a cutover, because there is none: only a long sequence of small, reversible moves.
- 1
Stand up the new platform against the existing database, read-only at first, so it renders real data before it is trusted to change any.
- 2
Build the first capability behind a flag that is off for everyone, and verify it against production data with internal users.
- 3
Turn it on for one location or one team, and leave it there through a full business cycle including month-end.
- 4
Expand the rollout in increments you can reverse in minutes.
- 5
Remove the legacy path, and the flag with it, once the capability has run clean for a sustained period.
The cost of coexistence is real. A shared database means every change must stay backward compatible with the system you are retiring, so architectural progress is balanced against not breaking inherited code on every step. Vocabulary collides too, because rebuilding a capability properly means adopting clearer names than the legacy jargon. Neither is an argument for a big bang. Both are the price of the business staying open.
Schema changes that cannot break the running system
While two systems read the same data, destructive migrations are not available to you. You cannot rename a column, tighten a constraint, or split a table in one step, because the old application is still running against the old shape. Every structural change becomes a sequence of safe steps, each deployable and reversible.
| Technique | What it does | Use it when | Failure mode to watch |
|---|---|---|---|
| Expand/contract | Add the new structure alongside the old, migrate readers and writers across, then remove the old one later. | The default for any schema change during coexistence: renames, splits, type changes, new constraints. | The contract step never happens, and the schema fills with dead columns nobody dares delete. |
| Dual write | Write the same information to both the old and new structures during the transition. | Both systems must accept writes to the same concept at once and neither can pause. | The writes are not in one transaction, so a partial failure silently diverges the copies. Reconcile, do not hope. |
| Backfill | Populate the new structure for existing rows in batches, outside the request path. | After the new structure exists and new writes land correctly, to bring history forward. | Running it as one long transaction, locking the table and causing the outage you were avoiding. |
| Shadow read | Serve the answer from the old path while computing it on the new one and comparing. | Correctness matters more than the change is visible: pricing, tax, commission, availability. | Logging mismatches nobody reads. Divergence needs a dashboard and a threshold. |
Two rules make this workable. Deploys and migrations are separate events, so a schema change can ship days before the code that needs it. And every migration is written to be safe if it runs twice, because one of them will.
Enforce the new architecture or you will rebuild the old one
The monolith you are replacing did not start tangled. Business rules ended up in controllers, views, and shared globals one deadline at a time. Nothing about a fresh codebase prevents that, and a rebuild that decays is worse than the original because it cost more.
The practice that holds is separating what the business does from how it is delivered (an application layer that orchestrates HTTP and holds no rules, a domain layer holding nearly all business logic, and an infrastructure layer implementing interfaces the domain defines), then enforcing those boundaries with automated architecture tests that fail the build. Not documentation. Not a diagram in the wiki. A test.
- The domain layer may not reference the application layer or any framework HTTP type
- Infrastructure implements domain interfaces and is never imported by the domain directly
- Business rules live in domain services, value objects, and specifications, not in controllers
- New screens assemble from the shared component library rather than bespoke UI
Flags, rollout, and the gates that make a mid-migration deploy ordinary
Mid-migration you deploy frequently into a system real businesses are using, while two codebases share a database. That is only survivable if a deploy is routine rather than a scheduled risk. Feature flags separate deploying code from turning it on, so a broken slice is switched off in seconds by support instead of reverted by engineering.
Flags have a cost. Each one doubles the paths through a capability, and dozens of stale flags are harder to reason about than none. Give every flag an owner and a removal date, and delete it in the release after rollout.
| Gate | What it catches | Where it runs |
|---|---|---|
| Static analysis and type checking | Type errors and defects before the code runs at all | Local, then blocking on merge |
| Unit tests over domain logic | Business rule regressions in isolation from the framework | Blocking on merge |
| Architecture tests | Layer violations that would let the design decay | Blocking on merge |
| Feature and integration tests | Broken workflows across the boundary between old and new | Blocking on merge |
| Browser-driven acceptance tests | Critical user flows failing in a real browser | Blocking on promotion to pre-production |
| Migration replay against a production-shaped database | Migrations that lock, time out, or are unsafe to rerun | Blocking on promotion to production |
Observability, so failures are visible before they are reported
During a migration your users are your integration test, and the question is whether you find out from a dashboard or from a phone call. The failure mode specific to this work is not the outage; it is the quiet divergence, where the new path produces a subtly different number and nobody notices for weeks.
- Error rate and latency reported per capability and per flag state, so old and new paths are comparable
- Business metrics, not just technical ones: orders created, invoices issued, jobs closed, tracked before and after each rollout step
- Divergence counters from shadow reads, with an alert threshold rather than a log file
- Structured logs carrying a correlation identifier that survives the hop between old system and new
- An explicit alert when a backfill stalls, because a stalled backfill is silent by nature
Cross-cutting concerns are the ones that get expensive
Capabilities migrate independently. Concerns that touch every capability do not, and permissions are the clearest example. In multi-location operations a user is not simply an admin or a clerk: one person may hold different roles at different storefronts, and an operator may run hundreds of locations. The model that works resolves a user, an action, and a location into one allow-or-deny decision, built as a well-tested domain concern.
Settle this before the first capability moves, because the arithmetic is unforgiving. Authorization reaches into every query, every screen, and every test. Getting it right once costs a bounded amount of work. Discovering the location dimension after eight capabilities have shipped means revisiting all eight, and the bugs are the invisible kind: the wrong person sees another store's data and nothing indicates a problem.
The new platform was built alongside the old one, preserving data compatibility and shifting workflows incrementally with a fallback path at each stage. Fifteen years of legacy behavior preserved, boundaries enforced in CI, no big-bang cutovers.
The same applies to auditing, tenancy, money and rounding, time zones, and notification routing. Decide each model once, in the domain layer, before capabilities depend on it. Finally, things not to do.
- Do not attempt a big-bang cutover weekend for a system the business depends on daily. The rollback plan is always a fiction.
- Do not keep the legacy path running as a hedge after a capability migrates. Two live implementations of one workflow will diverge.
- Do not treat the rebuild as a chance to redesign every process at once. Port the behavior first, then improve it.
- Do not measure progress in percentage of code migrated. Measure capabilities running in production and legacy paths deleted.
Common questions
- Can a monolith really be modernized with zero downtime?
- Zero unplanned downtime is achievable, and it comes from coexistence rather than from a careful cutover. Both systems run against a shared source of truth while capabilities move one at a time behind flags, so no single release changes what the whole business depends on. You should still expect brief planned maintenance windows for a small number of structural changes.
- How long does this take compared with a rewrite and a single cutover?
- Incremental migration usually takes longer in elapsed calendar time and costs less in total, because the expensive failure (a cutover that has to be reversed after a full rewrite is finished) never happens. The more useful comparison is time to first value: coexistence puts a real capability in production within months, while a big-bang rewrite delivers nothing until it delivers everything.
- Should the new system have its own database from day one?
- Usually not. A shared database is what makes coexistence possible, because both systems read consistent data without a synchronization layer that can drift. The cost is that every schema change has to stay backward compatible with the legacy code, which is real and constant. Separating the data store is a later step, taken per capability, once the legacy readers for that data are gone.
- What actually prevents the new system from becoming a monolith too?
- Automated architecture tests that fail the build when a layer boundary is crossed. Documented conventions and code review both work until a deadline, at which point business logic starts appearing in controllers again. Making the rule mechanical is the difference between a design that holds for a decade and one that decays in eighteen months.
- How do we know a migrated capability is correct before we rely on it?
- Run it in shadow first: serve the answer from the legacy path, compute it in parallel on the new path, and compare. Track the divergence rate as a metric with an alert threshold rather than as a log nobody reads. When divergence is at zero across a full business cycle, including month-end, the capability is ready for a real rollout.
- Who should own the migration inside our organization?
- One person with authority to say a slice is not ready, and enough context to weigh that against business pressure. Migrations fail when the decision to expand a rollout sits with whoever is under the most schedule pressure. The same owner should hold the flag inventory, because stale flags are a maintenance debt that accumulates silently.