Software modernization for systems you inherited

Most systems do not need a rewrite. They need an honest assessment, a safe order of operations, and someone willing to read the code nobody has opened in three years.

Who this is for

  • The people who wrote it have left. There is no documentation, and the one person who understood the billing rules resigned last year.
  • You are on a framework or runtime version that stopped receiving security patches, and your hosting provider or auditor has given you a date.
  • Your team has proposed a full rewrite. You suspect it will take longer than they say, and you want the reasoning stress-tested before you sign it off.
  • Every release is a manual ritual, and the rollback plan amounts to restoring last night's database backup.

What usually goes wrong

The problems this work exists to solve

Nobody can say what the system actually does

Before changing anything we build a behavioural record. We capture real request and response pairs from production for the highest-traffic paths, turn them into characterization tests, and run them against the code as it stands — not against what the code was supposed to do. We map the database first, because the schema and its foreign keys usually tell the truth the code obscures: which tables are written by cron jobs, which columns are dead, which 'unused' endpoint an integration partner still calls every night. Undocumented behaviour is not a reason to avoid a system. It is a reason to write the tests the original authors never did, and those tests are the deliverable that outlives us.

A rewrite has been proposed as the default answer

We treat rewrite-versus-refactor as a decision that has to be argued, not assumed. A rewrite is justified in a narrow set of cases: the runtime is end-of-life with no upgrade path in the same language, the core data model is wrong at the root so every feature fights it, or the specification is genuinely knowable because the system is thin and the business rules live elsewhere. Refactoring wins whenever behaviour is undocumented and business-critical, because a rewrite has to reproduce behaviour it cannot see. The real cost of a rewrite is rarely the code — it is the feature freeze, the two systems running in parallel, and the discovery, months in, of edge cases the old code handled silently. We write the recommendation down with the reasoning, subsystem by subsystem. Often the answer is a rewrite of one subsystem and a refactor of everything else.

The runtime is end-of-life and the upgrade fails immediately

Jumping several major versions at once produces a wall of errors with no isolated cause. We climb the ladder one version at a time — Node 14 to 16 to 18, Python 2.7 to 3.6 to a supported 3.x, PHP 5.6 upward, .NET Framework to .NET through a compatibility pass — pinning the whole dependency tree at each rung, running the test suite, and merging to trunk before the next step. Each step ships. We do not combine a runtime upgrade with a feature change or a refactor in the same commit range, because when something breaks you need exactly one candidate explanation. Where a dependency is abandoned and blocks the ladder, we decide explicitly: replace it, vendor it and patch it ourselves, or wrap it behind an interface and defer.

The database is the real legacy, and it cannot be taken offline

Schemas inherited from a decade of pressure tend to have nullable columns that are effectively required, business logic buried in triggers and stored procedures, and enum values encoded as free text. We migrate with expand–migrate–contract: add the new shape alongside the old, write to both, backfill historical rows in throttled batches, run a reconciliation job that compares old and new for a period, then move reads across, then drop the old columns. Contract is a separate, later change, and it is the step teams skip — leaving the schema permanently half-migrated. Where a move crosses engines or hosts, logical replication or change-data-capture carries the delta while the old database stays authoritative until reconciliation is clean.

The monolith is blamed for problems it did not cause

Slow releases, merge conflicts and fragile deploys are usually caused by a missing test suite, a manual deploy process and shared mutable state — none of which improve by splitting the deployment unit. Splitting first converts an in-process function call into a network call that can fail partially, and a database transaction into a distributed one. We decouple when there is a specific reason: a component with a genuinely different scaling profile, a compliance boundary that benefits from isolation, or a team ownership line that keeps colliding. Otherwise we do the cheaper thing first — enforce module boundaries inside the existing deploy unit, automate the release, then reassess. A modular monolith you can deploy in minutes beats six services nobody can trace across.

Dependency upgrades pass CI and break in production

Legacy test suites cover the code that was easy to test, which is rarely the code that breaks on upgrade — serialisation formats, timezone and locale handling, decimal and floating-point behaviour, TLS defaults, connection pool semantics. We pair upgrades with contract tests against integration partners in a sandbox, and where behaviour is hard to assert we dark-launch: run the upgraded path in production against live traffic, discard its output, and compare it with the old path's result. Differences get logged with the input that produced them. Only when the diff stream is quiet does the flag flip.

Scope

What you actually get

  • Codebase and infrastructure assessment

    A written read of what you actually have: module map, dependency inventory with end-of-life dates, database schema and its real constraints, integration points and who calls them, build and deploy mechanics, and the parts of the system with no test coverage at all. Written for a technical reader, with the uncomfortable findings included rather than softened.

  • Rewrite-versus-refactor recommendation

    A per-subsystem recommendation with the reasoning attached, so you can disagree with the reasoning rather than the conclusion. It covers what we would leave alone, what we would rewrite, what we would strangle incrementally, and in what order — because sequence is most of the risk.

  • A safety net before any change

    Characterization tests against current behaviour, captured production traffic for replay, contract tests at the integration boundaries, and a reproducible build. This is the first thing we ship on almost every engagement, and it is what makes the rest of the work boring in the good sense.

  • Seams and a routing facade

    A proxy or facade in front of the old system so traffic for a given route, tenant or account can be sent to old or new code on a flag, per request. This is the mechanism that makes a strangler-fig migration reversible: if the new path misbehaves, the flag goes back and nobody files an incident.

  • Runtime and dependency upgrade path

    The version ladder, the blocked dependencies with an explicit decision on each, and pinned lockfiles at every rung — delivered as a series of merged, deployed steps rather than one long-lived branch.

  • Database migration plan and jobs

    The expand–migrate–contract sequence, throttled backfill jobs with resumability and replica-lag checks, the reconciliation job that proves old and new agree, and the cutover and rollback runbook.

  • Handover documentation and decision records

    Architecture decision records that explain why each choice was made, runbooks for the operations that used to live in one person's head, and a written list of what remains undone with our view on its priority. We would rather you not need us next quarter.

Technology

Technology options

We are technology-agnostic. These are the choices we reach for, and how the decision actually gets made.

Safety nets

Characterization and golden-master testsProduction traffic capture and replayConsumer-driven contract testsFeature flagsShadow / dark-launch comparison

Traffic replay gives the highest fidelity but needs care with personal data and idempotency — replaying a payment call is not a test, it is an incident. Hand-written characterization tests are slower to produce but safe to run anywhere and readable by your team afterwards. We generally use replay for read paths and written tests for anything that mutates.

Seams and routing

Reverse proxy (nginx, Envoy, Traefik)API gateway routingIn-process facadeBranch by abstractionAnti-corruption layer

A proxy-level seam is simple and language-agnostic, but can only split on what is visible in the request — path, host, header, tenant. An in-process facade can split on domain logic the proxy cannot see, at the cost of touching the legacy code you were trying to avoid. Where the old data model would otherwise leak into new code, an anti-corruption layer earns its cost; where the two models are close, it is ceremony.

Data migration mechanics

Dual writes from the applicationChange-data-capture (Debezium, native CDC)Logical replicationBatched backfill jobsReconciliation and diff jobs

Dual writes are quick to implement and put the correctness burden in application code, where a failed second write leaves the stores divergent unless you add an outbox. CDC keeps the application clean and gives ordered, at-least-once delivery, but adds a streaming component someone then has to operate. We pick CDC when the migration will run for months or the write paths are scattered, and dual writes when there is one write path and a short window.

Target runtimes

Same-language major upgradePort to a supported sibling runtimeContainerising the legacy app unchangedManaged database and platform services

Staying in the same language keeps the behaviour and your team's knowledge, and is almost always cheaper — even when the language is unfashionable. Porting is worth it when the ecosystem itself is dead, not when a newer stack looks nicer. Containerising an unchanged legacy application buys reproducibility and a route off an unsupported host operating system without touching the code, which is often the right first move rather than the last.

Release safety during migration

Blue-green deploysCanary by tenant or percentagePer-request flag routingAutomated rollback on error-rate signal

Blue-green is simple and gives an instant revert, but doubles infrastructure during the switch and does not help when a fault only appears for one customer's data. Canary by tenant catches data-shaped bugs that percentage canaries miss, at the cost of a slower ramp. For migrations we usually route per request on a flag, because it lets a single account be moved back without redeploying anything.

How it runs

The shape of the engagement

  1. 01

    1. Read-only assessment

    We read the code, the schema, the deploy scripts and the incident history before proposing anything. Nothing changes in this phase. It ends with a written assessment and a recommendation you can take to your board, your investor or your own engineers — including the option of doing nothing.

  2. 02

    2. Stabilise the ground

    Reproducible build, a test run that passes, environments that resemble each other, and tracing in the old system so there is a baseline to compare against later. Modernization that starts before this step tends to produce arguments about whether something was already broken.

  3. 03

    3. Decide, and write the decision down

    Rewrite or refactor, per subsystem, recorded as decision records with the reasoning. We also name what we are explicitly not touching. This is where the debate happens, deliberately, before code is written rather than six months into a branch.

  4. 04

    4. Cut the first seam

    We pick the smallest slice that proves the mechanism — usually a read-heavy, low-risk route — and take it all the way through: facade, flag, new implementation, comparison, cutover. The first slice is about proving the migration machinery works, not about business value.

  5. 05

    5. Migrate incrementally, dual-run, compare

    Each subsequent slice ships behind a flag with the old path still authoritative. Where correctness matters we run both and log the differences. The work runs alongside your feature delivery rather than replacing it; a modernization that requires a feature freeze is a modernization that gets cancelled.

  6. 06

    6. Delete the old path

    Retirement is a deliverable, not an afterthought. Old routes removed, dead columns dropped, unused dependencies pulled, the facade simplified or taken out. A migration that stops at ninety per cent leaves you operating two systems permanently, which is worse than either one alone.

Engineering

Architecture, security and performance

The decisions that are expensive to change later, and where we stand on them.

Architecture

Strangler fig, with the cost stated honestly
Routing traffic through a facade and replacing the system behind it one route at a time takes more total effort than a clean rewrite, and it looks worse while it runs — you maintain two implementations, a facade and a flag matrix. What you buy is the ability to stop. At any point the work has delivered something and the system still runs. A big-bang rewrite has no value until the day it lands, which means it has no fallback on the day it does not.
The seam goes where the data separates, not where the code is ugly
The tempting extraction is the module with the worst code. The correct extraction is the one whose data can be separated without splitting a transaction. If two candidate components must write the same rows in the same commit to stay correct, splitting them turns a database guarantee into a distributed-transaction problem you will then solve with a saga, a compensation path and a reconciliation job. Sometimes that is worth it. Usually the ugly module should just be cleaned up where it is.
Branch by abstraction, never a long-lived rewrite branch
A rewrite branch that lives for months accumulates merge debt against a trunk that keeps moving, and defers the merge to the moment of maximum risk. Instead we put an abstraction over the behaviour being replaced, add the new implementation behind it on trunk, and switch by configuration. Both implementations sit in main, both are exercised by CI, and nobody spends a fortnight resolving conflicts in code they did not write.
The old system stays authoritative until the new one is proven
New code can read and write, but the old store or path remains the source of truth until a comparison job has been quiet for a meaningful period under real traffic — including a month-end, or whatever your actual peak is. Flipping authority is a separate, scheduled change with its own rollback plan, not a side effect of a deploy.
Sometimes the right architecture is the one you already have
Where the coupling lives in the data model rather than the deployment unit, decomposition does not help; nor does a rewrite on systems whose code was unattractive but correct and stable. Enforcing module boundaries inside a single deployable, adding tests and automating the release usually removes the symptoms teams attribute to the monolith, at a fraction of the risk. If we think your money is better spent elsewhere, we will say so during the assessment.

Security

End-of-life runtimes are a security finding, not just a tech-debt finding
An unsupported framework version means known vulnerabilities in the runtime and in transitive dependencies with no upstream fix. We build a dependency inventory with support and end-of-life dates, then order the work by reachability rather than advisory count — a critical advisory in a code path your application never calls matters less than a moderate one in your request handler. That ordering is what turns a hundred-line scanner report into a plan.
Legacy authentication needs a migration path, not a flag day
Inherited systems frequently store unsalted or fast-hashed passwords, hand-rolled session tokens, or secrets compared without constant-time functions. Passwords cannot be rehashed in bulk because the plaintext is gone, so we rehash on next successful login, mark migrated accounts, and set a date after which the remainder are forced through reset. Session and token formats get versioned so old and new can be honoured together through the overlap.
Rotate inherited secrets before you clean the history
Old repositories almost always carry credentials in config files and in git history. Rewriting history does not make a leaked key safe — anyone who cloned the repository, and every fork and backup, still has it. So the order is inventory, rotate, move to a secret manager, and only then scrub history if you still want to. The same applies to the shared administrator accounts on legacy servers: per-person credentials and an audit trail come before anyone starts changing things, or nobody can reconstruct who did what.
Migration widens the blast radius while it runs
Backfills, dual-write pipelines and diff jobs copy production data into new stores, into log lines and sometimes onto engineers' machines. We scope extracts to the columns actually needed, mask or synthesise personal and payment fields in anything non-production, hash values in comparison logs rather than printing them, and give the migration jobs short-lived credentials of their own. Where the data is regulated, that constraint shapes the migration design rather than being bolted on afterwards.
Modernization is the cheapest moment to shrink regulated scope
While code and data are already moving, it costs comparatively little to route sensitive fields through a narrow, isolated path — tokenising card data so most services never see a PAN, or separating identifiable health data from the rest of the schema. Doing the same thing later, as a standalone project, means paying the migration cost a second time.

Performance

Profile before you blame the architecture
The usual cause of a slow legacy application is not its shape but its queries: N+1 access patterns behind an ORM, missing or unused indexes, a reporting query running against the primary, an unbounded cache. Extracting that code into a service without fixing the query moves the same work behind a network hop and makes it slower. We instrument and profile the existing system first, and the perceived need for modernization is sometimes resolved during assessment by index and query work alone.
The migration mechanism has its own latency budget
A facade adds a hop. Dual writes add a second store's latency to every write, and a synchronous second write adds its failure modes too. Where the correctness model permits, we make the secondary write asynchronous through an outbox so the user-facing path is unchanged; where it does not, we accept the latency knowingly and put a limit on how long the dual-write period lasts.
Backfills are throttled, resumable and watched
A single statement updating a large table can lock it, blow out replication lag and take the product down at three in the morning. Backfills run in bounded chunks by primary key, checkpoint their progress so they resume rather than restart, watch replica lag and pause themselves when it grows, and run on a window you have agreed rather than whenever the job happened to deploy.
Inherited caches are usually correctness bugs waiting to be measured
Legacy systems collect caches added to hide a slow query, often with no invalidation and an unclear key. Migrating around them preserves the staleness and hides the underlying cost. We work out what each cache is really protecting, fix or remove the source problem, then reintroduce caching deliberately with a stated invalidation strategy and a measured hit rate.
Keep a baseline you can argue from
Tracing and latency percentiles go into the old system before anything changes, so that after cutover there is evidence rather than opinion about whether the new path is faster. Comparisons use the same load profile and the same percentiles — a median that improves while the tail gets worse is a regression for exactly the customers who complain.

Size of work

Where your project probably sits

A rough map so you can locate yourself before talking to us. Actual scope comes from a conversation, not a table.

LevelExampleEngagement shape
Focused taskTaking one application up a framework major version with its dependency tree pinned and the test suite green, or containerising a legacy service so it can leave an unsupported host operating system.One engineer, a few days, fixed before we start — usually one dependency-ladder step or one seam cut, with the test that proves behaviour did not change. From about Rs 25,000.
AssessmentA read-only review of an inherited codebase: module map, dependency and end-of-life inventory, schema analysis, and a written rewrite-versus-refactor recommendation with a sequenced plan.A few weeks, a small senior pair, no production changes. Ends in a document and a working session with your team; you are free to execute it without us.
ProjectOne bounded migration taken to completion — a subsystem strangled out behind a facade, a database moved with expand–migrate–contract, or a runtime ladder climbed across a whole application.Several months with a small dedicated team, working in slices, with a demo each fortnight and something deployable at the end of each one.
PlatformA long-running strangler-fig programme across a large system, running alongside your own feature delivery, with ownership handed to your engineers as each area is retired.A standing team on a quarterly plan, reviewed each quarter against what has actually been retired rather than what has been built.

Honest limits

When this is not what you need

Telling you this early is cheaper for both of us than discovering it in month two.

  • Your system works, it is on supported versions, and the only complaint is that it is old or written in something unfashionable. Modernization for its own sake is a bad purchase — if it is stable and cheap to run, spend the money on the product instead.
  • The product is being discontinued, replaced by a vendor platform, or the company is mid-acquisition. Freeze it and keep it patched. Software maintenance is the right service, and it costs a fraction of a migration you will throw away.
  • You have already decided on a full rewrite and want an execution partner who will not question it. We will question it, in writing, during the assessment. If the reasoning holds we will build it — but if you would rather not have that conversation, we are the wrong choice.
  • The system is a licensed vendor product or a heavily customised off-the-shelf platform whose source you do not own. That is a procurement and replatforming decision; the useful work is negotiating with the vendor or planning a data exit, not refactoring code you cannot legally change.

Questions

Common questions

  • Nobody who wrote this still works here. Can you still work on it?

    Yes, and this is the normal case rather than the exception. We start from the artefacts that cannot lie: the database schema, the production traffic, the deploy scripts and the error logs. Those tell us what the system does today, which is what matters, regardless of what anyone intended. The first thing we produce is a set of tests describing current behaviour, so from then on the system is documented by something executable.

  • How do you avoid breaking things nobody knew were there?

    By not relying on knowing. Changes go out behind flags with the old path still available, new code runs in parallel with the old and their outputs are compared on live traffic before anything switches, and cutovers happen per route or per tenant so a problem affects one slice rather than everyone. Integration partners are covered by contract tests, because the endpoint you were told is unused is often the one a partner calls nightly.

  • Do we have to stop shipping features while this happens?

    No, and we would advise against it. A modernization that blocks product work loses its funding at the first commercial pressure. The strangler-fig approach exists precisely so migration and feature delivery share a trunk: new features go into the new path where that area has been migrated, and into the old one where it has not. It costs some efficiency and buys the programme its survival.

  • We are on an end-of-life runtime with a deadline. What is the fastest safe path?

    Usually not the one that looks fastest. We climb version by version with the dependency tree pinned at each step and each step deployed, because a multi-version jump produces failures with no isolated cause. If the deadline is really about the host operating system rather than the language, containerising the application unchanged often buys genuine time without touching the code. Where a dead dependency blocks the ladder, we decide explicitly whether to replace it, vendor and patch it, or wrap it and defer.

Keep reading

Related work and reading

Related services

  • Software architecture consulting

    If the open question is what the target state should look like — service boundaries, data ownership, whether to split at all — that decision is worth making before the migration starts.

  • Software testing

    The safety net comes first. If you want the characterization tests, traffic replay and contract tests built without committing to a migration, that is a standalone engagement.

  • Software maintenance

    For when the honest answer is to keep the system patched and stable rather than change it, or for after the migration when someone needs to hold the pager.

  • Cloud & DevOps

    Most modernization stalls on deploys. Reproducible builds, automated releases and tracing are usually the first thing to fix, and often remove half the perceived need for a migration.

  • API development

    The facade in front of a legacy system is an API design problem: versioning, contracts, and an anti-corruption layer that stops the old data model leaking into new code.

Technologies

Industries

Send us the repository and the worry

Tell us what you inherited, what version it is stuck on, and what you are being asked to decide. The first conversation is about whether a migration is even the right spend — sometimes it is index and query work, sometimes it is maintenance, sometimes it is a rewrite of one subsystem and nothing else. If you want it in writing, a read-only assessment gives you a plan you own, whether or not we build it.