Legacy Modernization

Refactor or wrap first, when QA owns an untested legacy app

You inherited a codebase with no trustworthy tests, so the first refactoring decision is not architectural; it is evidentiary. My position is blunt: choose characterization-harness refactoring before extraction unless production telemetry already proves behavior. Some engineers will disagree because the strangler pattern feels cleaner, but QA owns the blast radius when “clean” code changes undocumented outcomes.

The safer default is to freeze behavior before you improve design

For a QA engineer, legacy application refactoring starts with one question: can you tell whether the system still does what users depend on? If the answer is no, moving code into services, modules, or new frameworks is premature because every boundary change creates a second place for hidden behavior to diverge.

Application Refactoring Strategies for Legacy Software treats refactoring moves as choices, but an inherited untested system makes test observability the first constraint because QA cannot judge a cleaner shape that silently changes behavior.

The approach I would choose first is Characterization Harness First: pin current behavior with tests around public seams, then refactor inside those seams. Use pytest 8 with –maxfail=1, coverage.py 7 with –branch, JUnit 5.10, ApprovalTests, Jest 29 with –runInBand, or Golden Master snapshots, depending on the stack. These tools are not magic, but they force the team to write down what the program actually does before arguing about what it should have done.

I would not begin by demanding broad unit-test coverage across private methods, because legacy internals are usually unstable and over-mocking them preserves bad design for the sake of a number. I would also not start with a big-bang rewrite, because QA loses the ability to compare old and new behavior at small increments.

A practical first target is modest: my starting budget is 30 characterization tests around the highest-risk workflows, not 300, because the early goal is to expose behavioral seams rather than exhaustively describe the system. Another value to tune is a 15-minute maximum for the refactoring safety suite in CI, because a slower suite will be skipped locally and distrusted during urgent releases.

A tiny harness can be this plain:

def legacy_total(cents, tax_bps):
    return round(cents * (10000 + tax_bps) / 10000)

def test_known_invoice_totals():
    samples = [(10000, 725), (1999, 875), (2500, 0)]
    observed = [legacy_total(c, r) for c, r in samples]
    assert observed == [10725, 2174, 2500]

Run that with python -m pytest -q. In a real system, replace the function with a call to a CLI, HTTP endpoint, controller action, stored procedure wrapper, or batch job. The point is not that the test is beautiful; the point is that it catches the first accidental behavior change cheaply.

Production Strangler wins only when the boundary is already observable

The competing approach is Production Strangler First: route one capability away from the old application into a new module or service, then expand the new path while retiring the old path. This approach wins when the behavior is already visible at a stable interface, because QA can compare responses, logs, metrics, and contract tests without inspecting tangled internals.

Here is the explicit comparison:

  • Characterization Harness First wins when the codebase has weak or missing tests, unclear requirements, shared databases, undocumented side effects, or heavy batch logic. It costs time up front because QA and developers must capture existing outputs before refactoring, and it may preserve ugly behavior temporarily because the harness records reality rather than intent.
  • Production Strangler First wins when the feature has a narrow API, clear ownership, stable data contracts, and enough observability to compare old and new paths. It costs operational complexity because routing, dual writes, contract drift, rollbacks, and monitoring become part of the test problem.

I prefer Characterization Harness First for an untested inherited system because unknown behavior is a testing problem before it is an architecture problem. The strangler approach can still be excellent, but it is risky as the default because it moves failure from code review into production routing unless old and new behavior can be compared continuously.

If the team chooses Production Strangler First, QA should insist on concrete safeguards: OpenAPI 3.1 schemas for HTTP contracts, Pact specification v4 for consumer-driven contracts, WireMock 3 with –global-response-templating for repeatable downstream stubs, Testcontainers 1.19 with PostgreSQL 16 or MySQL 8 for disposable integration environments, and OpenTelemetry 1.30 traces with W3C Trace Context headers. These tools reduce ambiguity because they make interface expectations executable instead of conversational.

A vendor-published limit worth knowing is SonarQube’s common Cognitive Complexity threshold of 15 for many method-level rules, because a method above that level often becomes hard to characterize from the outside without first adding seams. That number should not decide the design, but it is a useful triage signal when QA needs to find code that hides branches.

Your decision should follow failure signals, not modernization vocabulary

Do not pick between the two approaches by asking whether the application is a monolith, service, module, or “legacy platform,” because those labels do not tell QA whether behavior can be verified. Pick by measuring the failure surface.

Use these decision signals:

  • Choose Characterization Harness First if the last 90 days of release notes or incident tickets show recurring regressions in the same workflows, because repeated failures imply the team does not understand the behavior well enough to move it safely.
  • Choose Characterization Harness First if database tables are shared by multiple jobs or screens, because extraction can create inconsistent writes before anyone knows which side effect matters.
  • Choose Production Strangler First if the feature already has a stable HTTP, gRPC, SOAP, CLI, or message interface, because QA can run old and new paths against the same inputs and compare outputs.
  • Choose Production Strangler First if the system already emits reliable logs, traces, and metrics, because observability turns routing changes into monitored experiments instead of guesses.

For metrics, use ones that help QA make a decision rather than ones that decorate a dashboard. Branch coverage from coverage.py 7 or JaCoCo 0.8.11 matters more than raw line coverage because untested decisions cause most refactoring surprises. Mutation score from PIT 1.15 matters when Java tests look green but weak, and a mutationThreshold of 50 is a sensible value to tune early because a stricter gate can block the first useful tests before the team has learned how the system behaves.

Change failure rate from DORA-style delivery metrics is useful if it is measured from deployment records rather than guessed, because QA needs a history of what actually broke. Escaped defect rate is useful only when defect intake is consistent, because inconsistent triage turns the metric into a measure of reporting habits.

Do not let “coverage percentage” settle the decision by itself, because 80 percent line coverage can still miss the branch that refunds money, skips authorization, or corrupts a nightly file. A measured baseline of 0 meaningful regression tests is enough to justify harness-first work, because the absence of executable expectations is more dangerous than low architectural elegance.

The harness has to test seams that users and operators can recognize

The first tests should surround recognizable behavior: invoice totals, permission decisions, import results, generated files, API responses, reconciliation counts, scheduled job outputs, and audit log entries. Those seams matter because QA can validate them with product owners, support tickets, logs, or production samples without pretending to understand every private function.

I would treat Application Refactoring Best Practices for Modernization as a later checklist, not the opening move, because best practices become safer after the team has executable evidence that refactoring preserved behavior.

Use ApprovalTests when outputs are verbose, because reviewers can approve whole JSON, XML, CSV, or text snapshots faster than hand-writing every assertion. Use JSON Schema draft 2020-12 or OpenAPI 3.1 for payload shape, because schema validation catches contract drift without overfitting to field order. Use Jest 29 snapshots carefully with –updateSnapshot locked behind review, because careless snapshot updates bless regressions with one command.

For Java, combine JUnit 5.10, AssertJ 3.25, JaCoCo 0.8.11, and PIT 1.15; for .NET, combine xUnit.net 2.6, FluentAssertions 6, Coverlet with /p:CollectCoverage=true, and ReportGenerator 5; for JavaScript, combine Jest 29 or Vitest 1, Istanbul/nyc 15, and MSW 2 for API mocks. These combinations work because they give QA assertions, coverage, and controlled dependencies instead of relying on manual exploration after every refactor.

Keep the suite small enough to trust. One threshold I would enforce in GitHub Actions or Jenkins is that the fast characterization suite runs on every pull request, while longer Testcontainers or end-to-end Playwright 1.40 checks can run on merge or nightly. This split is defensible because fast feedback catches accidental behavior changes during refactoring, while heavier environments catch integration problems without blocking every local edit.

When a test fails, resist the urge to “fix” the expected output immediately, because the failure is evidence that either behavior changed or the original behavior was finally exposed. QA should ask for a short decision record: keep legacy behavior, intentionally change it, or isolate it behind a compatibility layer. That record matters because future refactoring needs a trail of intentional changes, not tribal memory.

Start with the seam that scares you most tomorrow

Pick one workflow that has broken before, capture five production-like inputs, and write characterization tests around its outermost callable seam. Run them with branch coverage enabled, add the command to CI, and refuse refactoring in that area until the harness is green. After that, decide whether the next move is internal cleanup or a strangler route.

Eugene started his career at Chudovo in 2015. The expert is responsible for advisory services that include but are not limited to close communication with the clients, analyzing their needs, designing tailored software strategies, etc. Throughout the practice at Chudovo, an IT consultant took part in over 17 projects, covering a wide range of client services and solving customer pains from the business perspective. With Eugene’s involvement, the customers were able to build an efficient strategy for business growth as well as to set up a long-term technology roadmap. Eugene takes an active part in software development projects in the role of IT consultant and composes proposals for the customers.