Legacy Modernization
Two Systems, One Database: What Breaks When Legacy and Modern Run Side by Side
How a rewrite that passed every functional test still produced financial records that quietly drifted apart.
Project Overview
How a rewrite that passed every functional test still produced financial records that quietly drifted apart.
The setup
A multi-location education services provider had been running the same PHP application for over a decade. It managed everything: student enrolments, lesson scheduling, billing cycles, recurring payments, invoicing, and revenue reporting. It worked. It was also increasingly expensive to change.
The modernization plan was the standard one, and a sound one: build the replacement in Node and React, migrate capability by capability, keep both systems pointed at the same production database, and retire the old stack once parity was proven. A strangler-fig migration.
Months in, the new system was handling enrolment creation. It passed its tests. Staff could create an enrolment, see the lessons appear, and move on.
Then the billing complaints started.
The symptom
Customers were seeing payment due dates that didn't match their billing schedule. Some lessons appeared unpaid when payment had been taken. Some accounts showed balances that no one could reconcile against the invoices behind them.
Each complaint looked like its own bug. The team initially triaged them that way — a due-date bug here, an allocation bug there. That framing turned out to be the expensive mistake.
The root cause
The old system created an enrolment and finished its billing setup in one tightly-coupled path. Creation flowed directly into review, review into confirmation, and confirmation into the billing graph:
flowchart LR
accTitle: Legacy enrolment billing flow
accDescr: Enrolment creation proceeds through lesson review, confirmation, payment cycle creation, lesson linking, due date normalization, and balance update.
A[Create enrolment] --> B[Review lessons]
B --> C[Confirm]
C --> D[Create payment cycles]
D --> E[Link lessons to cycles]
E --> F[Normalize due dates]
F --> G[Update balance]Every step was chained to the one before it, largely through model lifecycle hooks. Ugly by modern standards — and impossible to leave half-finished.
The rewrite did what good modern design tells you to do: it separated concerns. Enrolment creation became one service. Billing wiring became a different service, triggered by a different flow.
flowchart TD
accTitle: Modern enrolment and billing flow
accDescr: Enrolment creation writes draft lessons, then either completes billing setup or leaves an incomplete billing graph that causes payment drift.
A[Create enrolment] --> B[Write course, schedule, enrolment, draft lessons]
B --> C[Due date = lesson date placeholder]
C --> D{Does a follow-up flow run?}
D -->|Yes| E[Create payment cycles]
E --> F[Link lessons to cycles]
F --> G[Recalculate due dates]
D -->|No| H[Enrolment exists with no billing graph]
H --> I[Drifting due dates, unallocated payments]
style H fill:#5a1e1e,color:#fff
style I fill:#5a1e1e,color:#fffThe new architecture is, in isolation, better. Smaller services. Clearer boundaries. Testable in pieces.
But the old system's coupling wasn't only coupling. It was also an enforced invariant: an enrolment could not exist without its billing graph, because the same call stack built both. When the rewrite split them apart, that guarantee quietly became a convention — and conventions don't hold across every code path.
If the follow-up flow ran, everything was correct. If a code path created an enrolment and didn't route into one, the enrolment existed with lessons carrying placeholder due dates and no payment-cycle membership at all. No error. No failed test. Just a record that was structurally incomplete in a way the database schema permitted.
What made it hard to find
The failure surfaced weeks downstream from where it happened. An enrolment created in one month produced a payment-allocation complaint in the next, by which point the batch jobs, invoice runs, and manual staff corrections in between had thoroughly obscured the origin.
Worse, the symptoms were diverse. Wrong due dates, missing allocations, unreconcilable balances, and reporting discrepancies all traced back to the same structural gap — but presented as four unrelated bugs to four different people.
The turning point was reframing. The team stopped triaging individual complaints and started asking a different question: what does a structurally complete enrolment look like, and how many records fail that test?
That produced a set of explicit invariants — assertions like every confirmed lesson belongs to exactly one active payment cycle, and every lesson's due date derives from the first lesson in its cycle. Written as SQL, these became detectors that could be run against production data to count affected records instead of waiting for customers to report them.
The fix, in three parts
Repair what's broken. Targeted data migrations, scoped narrowly and deliberately. The first covered only future unpaid lessons on monthly billing with confirmed due-date drift — the safest possible slice. Paid and historical records were explicitly excluded, because correcting money that has already moved is a business decision, not an engineering one.
Close the gap. Ensure every path that creates an enrolment also completes its billing graph, with the invariants enforced at the boundary rather than assumed.
Keep measuring. The invariant detectors became permanent monitoring. Drift is now caught by a query, not a complaint.
The transferable lesson
When you decompose a monolith, you inherit its data. You do not automatically inherit the invariants it enforced — especially the ones that were never written down, and instead lived implicitly in the shape of a call stack.
Before splitting a tightly-coupled flow, it is worth asking: what is this coupling guaranteeing? Sometimes the answer is "nothing, it's just old." Sometimes the answer is "the financial integrity of every record in the system."
Those look identical in a code review. They are not identical in production.
The practical safeguard is to write the invariants down as executable assertions before the split, and run them continuously afterward. A monolith enforces its rules by construction. A distributed system has to enforce them on purpose.
Modernizing a legacy platform with live financial data? We help teams plan migrations where correctness is provable, not hoped for. Get in touch.
How we drive results
Turning Strategy into Measurable Business Impact
Our case studies reflect a consistent delivery model focused on outcomes, helping organizations modernize technology, reduce risk, and accelerate growth through practical, scalable solutions.
Outcome-Driven Strategy
Every engagement starts with clear business objectives, success metrics, and a roadmap aligned to real operational and financial outcomes.
Proven Execution Model
We apply proven frameworks, agile delivery, and industry best practices to execute complex initiatives with speed, quality, and predictability.
Secure & Scalable Delivery
Our solutions are built with security, compliance, and scalability at the core, ensuring long-term resilience and sustainable growth.