# How we rescue and refactor: a platform migrated off a low-code tool, one year later

In 2025 a US client handed us a business platform that runs their partner programme: onboarding, product listings, monthly earnings, payouts in several currencies, and the operations team's daily tools. It had recently been migrated from a low-code platform to a custom TypeScript stack. Hundreds of partner records had been imported by script. The data model still had the shape of the old tool. Releases were rare and each one was a small event.

A year later the same platform ships small changes every week, each one verified in production before the ticket is closed, and the team that depends on it opens the ticket board rather than an email thread when something looks wrong. Nothing was rewritten. This is how we got from one to the other, and it is the method we apply to every rescue.

## Weeks one and two: read everything, touch nothing

The audit is read-only. Two weeks of reading the code, the database, the infrastructure and the bill, and talking to the people who use the system every day. The output is a risk map: what is fragile, what is expensive, what is fine, and in which order to deal with it.

On this platform the first item on the map was not a technical one. The money path, from an order in the e-commerce system to a partner's monthly statement, ran through three services, two databases and a spreadsheet the operations team kept on the side "to check". Nobody could say with certainty that the statement was right. That became priority one, ahead of the slow admin page and the flaky deploy, because a wrong payout costs trust, and trust is the thing a rescue is really restoring.

The second item was invisible to the client: the deploy pipeline reused an image tag when only configuration changed, so some deploys silently did nothing. The third was a stack approaching the CloudFormation resource limit, which would have blocked all infrastructure changes at the worst possible moment.

<div class="article-figure">
<svg viewBox="0 0 900 260" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The five phases of a rescue: audit, stabilise, characterise, refactor, modernise, each with a gate before the next">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="40" y="34" fill="#f1f3ff" font-size="15" font-weight="700">Five phases, and a gate between each</text>
<rect x="40" y="80" width="140" height="54" rx="8" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="110" y="103" text-anchor="middle" fill="#f1f3ff" font-weight="600">1 · Audit</text>
<text x="110" y="121" text-anchor="middle" fill="#9aa3c7">read-only, 2 weeks</text>
<rect x="210" y="80" width="140" height="54" rx="8" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="280" y="103" text-anchor="middle" fill="#f1f3ff" font-weight="600">2 · Stabilise</text>
<text x="280" y="121" text-anchor="middle" fill="#9aa3c7">observe · back up · fix</text>
<rect x="380" y="80" width="140" height="54" rx="8" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/>
<text x="450" y="103" text-anchor="middle" fill="#f1f3ff" font-weight="600">3 · Characterise</text>
<text x="450" y="121" text-anchor="middle" fill="#9aa3c7">tests pin behaviour</text>
<rect x="550" y="80" width="140" height="54" rx="8" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="620" y="103" text-anchor="middle" fill="#f1f3ff" font-weight="600">4 · Refactor</text>
<text x="620" y="121" text-anchor="middle" fill="#9aa3c7">along the seams</text>
<rect x="720" y="80" width="140" height="54" rx="8" fill="#151b2e" stroke="#9aa3c7" stroke-width="1.5"/>
<text x="790" y="103" text-anchor="middle" fill="#f1f3ff" font-weight="600">5 · Modernise</text>
<text x="790" y="121" text-anchor="middle" fill="#9aa3c7">framework, db, cloud</text>
<g stroke="#2a3150" stroke-width="2" fill="none">
<line x1="180" y1="107" x2="210" y2="107"/><line x1="350" y1="107" x2="380" y2="107"/><line x1="520" y1="107" x2="550" y2="107"/><line x1="690" y1="107" x2="720" y2="107"/>
</g>
<text x="110" y="170" text-anchor="middle" fill="#9aa3c7">gate: written risk map</text>
<text x="280" y="170" text-anchor="middle" fill="#9aa3c7">gate: a restored backup</text>
<text x="450" y="170" text-anchor="middle" fill="#9aa3c7">gate: money path tested</text>
<text x="620" y="170" text-anchor="middle" fill="#9aa3c7">gate: weekly releases</text>
<text x="790" y="170" text-anchor="middle" fill="#9aa3c7">gate: handover docs</text>
<text x="450" y="218" text-anchor="middle" fill="#9aa3c7">You can stop after any phase with a better system than before.</text><text x="450" y="236" text-anchor="middle" fill="#9aa3c7">Most clients stop after 4 and keep us for a few hours a month.</text>
</g>
</svg>
</div>

## Stabilise: make it observable before making it better

The first changes are never features. They are the things that let you change the system safely: structured logs with a request ID, traces across the three services, alerts that map to a runbook page, and a backup that has been restored at least once into a non-production account. On this platform we also replaced a health check that reported healthy while the database was unreachable, and fixed the deploy so that a configuration change produced a new image ([same-tag deploy and the deploy script without CI](/en/blog/same-tag-deploy-and-the-deploy-script-without-ci)).

Then the two or three fixes that stop the weekly fire. Here it was the CSV export that timed out on large months, fixed by streaming the file row by row so the proxy never closed an idle connection, and an admin page that took twenty seconds because a server-side calculation ran on half a CPU. Neither is glamorous. Both were the reason the operations team had stopped trusting the tool.

Stabilisation also meant a process. Every issue goes on the client's ticket board with a clear status; we investigate, fix on the development branch, deploy to production, verify in production with real data, and only then move the ticket to a "verify" column for the client to confirm. Nothing is closed on the strength of a unit test. That column did more for the relationship than any technical change.

## Characterise: pin down what the system does before changing it

Legacy code has no tests, or tests that describe what someone hoped it did. Before refactoring, we write characterisation tests: tests that capture what the system does today, on real data, so that every later change is checked against reality instead of memory.

For the money path we built exactly that, and it found the bug the operations team's side spreadsheet had been hiding. Orders arrive in several currencies. The earnings calculation applied the exchange rate of the day the statement was generated instead of the rate on the day of the order, so a partner's earnings changed depending on when someone clicked "generate". A partner noticed the number did not match their own records and wrote in.

The fix was conceptually simple: the rate is captured on the order at the moment it is recorded and never recomputed. The work was in the details: a helper for JSON columns that arrive as strings from the database's HTTP data API, a backfill that recomputed the history with the correct rates while keeping an audit trail of what changed, a comparison of every statement before and after, and a verification in production against the partner's own numbers. Three related issues reported by the client's finance lead were fixed and verified the same week. The characterisation tests now run on every pull request, and the side spreadsheet is gone.

<div class="article-figure">
<svg viewBox="0 0 900 280" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The money path before and after: before, the exchange rate was applied at statement time; after, the rate is stored on the order at recording time and statements are locked per month">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="40" y="34" fill="#f1f3ff" font-size="15" font-weight="700">The money path, before and after</text>
<text x="40" y="78" fill="#ff6b8a" font-size="13" font-weight="600">Before</text>
<rect x="120" y="60" width="120" height="40" rx="8" fill="#151b2e" stroke="#9aa3c7"/>
<text x="180" y="85" text-anchor="middle" fill="#f1f3ff">Order (EUR)</text>
<rect x="280" y="60" width="150" height="40" rx="8" fill="#151b2e" stroke="#9aa3c7"/>
<text x="355" y="85" text-anchor="middle" fill="#f1f3ff">Statement generated</text>
<rect x="470" y="60" width="170" height="40" rx="8" fill="#151b2e" stroke="#ff6b8a"/>
<text x="555" y="78" text-anchor="middle" fill="#f1f3ff">Rate of that day applied</text>
<text x="555" y="93" text-anchor="middle" fill="#ff6b8a">changes every time</text>
<rect x="680" y="60" width="180" height="40" rx="8" fill="#151b2e" stroke="#ff6b8a"/>
<text x="770" y="85" text-anchor="middle" fill="#f1f3ff">Payout ≠ partner's records</text>
<g stroke="#2a3150" stroke-width="2" fill="none"><line x1="240" y1="80" x2="280" y2="80"/><line x1="430" y1="80" x2="470" y2="80"/><line x1="640" y1="80" x2="680" y2="80"/></g>
<text x="40" y="178" fill="#4fffb0" font-size="13" font-weight="600">After</text>
<rect x="120" y="160" width="120" height="40" rx="8" fill="#151b2e" stroke="#9aa3c7"/>
<text x="180" y="185" text-anchor="middle" fill="#f1f3ff">Order (EUR)</text>
<rect x="280" y="160" width="150" height="40" rx="8" fill="#151b2e" stroke="#4fffb0"/>
<text x="355" y="178" text-anchor="middle" fill="#f1f3ff">Rate stored on order</text>
<text x="355" y="193" text-anchor="middle" fill="#4fffb0">never recomputed</text>
<rect x="470" y="160" width="170" height="40" rx="8" fill="#151b2e" stroke="#4fffb0"/>
<text x="555" y="178" text-anchor="middle" fill="#f1f3ff">Statement from stored rates</text>
<text x="555" y="193" text-anchor="middle" fill="#9aa3c7">same result every time</text>
<rect x="680" y="160" width="180" height="40" rx="8" fill="#151b2e" stroke="#4fffb0"/>
<text x="770" y="178" text-anchor="middle" fill="#f1f3ff">Month locked after payout</text>
<text x="770" y="193" text-anchor="middle" fill="#9aa3c7">audit trail of changes</text>
<g stroke="#2a3150" stroke-width="2" fill="none"><line x1="240" y1="180" x2="280" y2="180"/><line x1="430" y1="180" x2="470" y2="180"/><line x1="640" y1="180" x2="680" y2="180"/></g>
<text x="450" y="245" text-anchor="middle" fill="#9aa3c7">Characterisation tests run this path on every pull request with real historical orders. The side spreadsheet is gone.</text>
</g>
</svg>
</div>

## Refactor along the seams, never in a branch called "v2"

With observability, backups and tests in place, refactoring becomes ordinary work. We refactor along the seams that matter: the paths that block the next feature, the paths that wake people up, the paths a new engineer cannot read. Never a big-bang branch that diverges for three months.

On this platform the seams were the data imported from the old tool. Hundreds of partner profiles carried fields shaped by the low-code platform's model, and several hundred more were added later through three different import scripts. We made one source of truth per field, backfilled more than a thousand profiles from it, and deleted the scripts. A batch of partner codes had been created with a trailing hyphen and had spawned duplicate landing collections in the e-commerce system; we removed forty-nine duplicates and normalised twenty-seven codes, and deliberately left a handful with non-Latin characters untouched because the cost of touching them exceeded the benefit. A race between two writers on a secondary index in DynamoDB was fixed with a conditional write.

Feature flags did the rest. A product type that should no longer be created got a kill switch in configuration rather than a code deletion, so the operations team could turn it back on if a customer asked. Every refactor shipped behind a flag or in a shape that could be reverted with one deploy.

## Modernise once it is safe to change

Modernisation is the last phase, not the first, because it is only safe once the system can be observed, backed up and tested. Here it meant infrastructure: the stacks were split before they hit the CloudFormation limit ([CloudFormation 500-resource limit: split a stack](/en/blog/cloudformation-500-resource-limit-split-a-stack)), the container rollout moved inside the CDK deploy instead of a separate CLI step, each environment got its own custom domains, and the deploy pipeline stopped rebuilding three applications when one file changed. Framework versions were upgraded one major at a time, each behind the characterisation tests.

In other rescues this phase has been a PHP and Laravel version jump across five majors, a MySQL to PostgreSQL migration done with a dual-write period and no downtime ([zero downtime is a schema migration discipline](/en/blog/zero-downtime-is-a-schema-migration-discipline)), or a move from a single hand-built server to containers and infrastructure as code. The sequence is always the same and the system keeps running throughout.

## When the answer is "rewrite this part"

Sometimes it is. A rewrite is justified when the domain model is fundamentally wrong for what the business now does, or when the platform underneath is dead. Even then the honest answer is usually "rewrite this part", not "rewrite everything": carve out the module, put it behind an interface, replace it, keep the rest. The audit says which, in writing, with the reasoning. We have said "rewrite" once this year and "do not rewrite" many times, and we do not sell the rewrite because we do not bill differently for it.

If you have a system that everyone is afraid to deploy, or a vendor quote for a full rewrite that you would like a second opinion on, [send it to us](/contact). The audit is two weeks, read-only, and the risk map is yours whatever you decide.
