Legacy Modernization

Junior devs can modernize legacy code one seam at a time

A legacy order endpoint can look simple until a “small” pricing change exposes undocumented rounding rules, retries, and database writes. I would introduce refactoring at one observable transaction boundary, keep the existing path in control, and change its internals only after comparing outputs. A broad cleanup feels faster because it removes ugly code immediately; it is harder to verify because too many behaviors move at once.

The first boundary should follow a user action, not the class diagram

Suppose you maintain a Flask 2.3 application backed by PostgreSQL 14. Its POST /orders/quote handler reads customer terms, calculates a total, and returns JSON over HTTP/1.1. The calculation lives in a large module with database queries and response formatting mixed together. Your task is to change how totals are calculated without rewriting the endpoint.

Start by tracing one request from the route to the returned status code. Record the inputs the calculation actually uses: perhaps line prices, a customer discount, a tax rate, and a stored rounding preference. Record the observable outputs too: JSON field names, decimal formatting, error responses, and whether the request writes anything. A function name such as calculate_total is not a reliable boundary if it secretly reads a global database session; the HTTP transaction is observable from outside the codebase, so it gives you a firmer place to compare behavior.

I disagree with the decision rule implied by Refactor or wrap first, when QA owns an untested legacy app: I would choose the transaction boundary before choosing between wrapping and refactoring, because QA ownership does not reveal which dependencies or outputs the code hides. Ask QA for examples of disputed quotes, but inspect the call path yourself. A test case that says “the total is 110.00” is useful only if you also know which customer terms and database state produced it.

Write down a small contract for this endpoint before moving code. OpenAPI 3.0.3 can describe its request and response shapes, but do not generate a new server from that document; generation would introduce another moving part before you know whether the current implementation matches the document. Use curl to capture representative responses from a controlled environment, then store redacted fixtures in Git. Include a successful quote, a rejected input, and a case involving rounding. Redact names and account identifiers because a characterization test does not need production personal data.

I would not split the module into services or replace its ORM at this point, because either change expands the set of possible causes when a quote differs. If the application already uses SQLAlchemy 1.4, leave its session handling alone for now. Identify the smallest place where inputs can be gathered into plain values and the current result can be returned unchanged. That is the seam you will test and, later, use to introduce the replacement calculation.

Characterization tests should preserve surprises before fixing them

A characterization test asks what the application does now, not what a cleaner design ought to do. Use pytest 8 to send requests through Flask’s test client, with a database fixture that makes customer terms explicit. Assert the status code and relevant JSON fields rather than an entire response if it contains timestamps or generated IDs. Keep a separate note for behavior you believe is wrong; changing it while establishing the baseline would make a failing test ambiguous.

Test the seams most likely to hide behavior. Does an absent discount mean zero, or does it trigger an error? Does the endpoint round each line before summing, or round once at the end? Does a repeated request merely calculate a quote, or create a draft order twice? These questions matter because a new pure calculation function cannot safely replace a handler whose database writes are part of its observable behavior. A database transaction rolled back after each test can limit fixture leakage, but it cannot prove that an external email or queue message was not sent; stub those collaborators explicitly.

Coverage.py 7, often run through pytest-cov 5, can tell you which lines the tests execute. I would not aim for a project-wide coverage percentage yet, because a high aggregate can conceal an untested rounding branch in the one endpoint you are changing. Instead, inspect branch coverage for the touched path and name each missing case. If a branch depends on a database value that nobody understands, add a fixture that exercises it before extracting the code.

For a baseline, imagine that a repeatable staging run of 200 quote requests measures a p95 response time of 180 ms and finds 2 distinct rounding outputs. Those figures are an example of measurements to collect, not targets to copy. Record the fixture revision, PostgreSQL version, and application commit alongside your own results; otherwise a later timing difference might come from changed data rather than your refactor. Use EXPLAIN (ANALYZE, BUFFERS) on a staging SELECT if the request appears query-bound, since that command executes the query and should not be applied casually to a mutating statement.

Keep the tests close to the public boundary even after you extract a pure function. Unit tests for that function will be faster and more precise, but an endpoint test can still catch a missing discount field, a changed HTTP status, or serialization that turns 110.00 into 110.0. Both levels earn their place because they fail for different reasons.

Shadow comparison beats a replacement flag while behavior is uncertain

There are two reasonable ways to introduce the new calculation. Shadow comparison runs old and new calculations on the same inputs but returns the old result; it wins when behavior is poorly documented, at the cost of extra computation and discrepancy logging. A replacement flag returns the new result for selected requests; it wins once tests and comparisons support the change, at the cost of a live rollback decision if customers see a difference. I would start with shadow comparison because it keeps an uncertain implementation from determining the response.

The following Python 3 example is self-contained and runs as written. It models a small seam around an existing float calculation; in your codebase, move the existing expression into the old path without “improving” it during extraction.

import logging
from decimal import Decimal

def total(amount, rate, compare=False):
    old = round(amount * (1 + rate), 2)
    if compare:
        new = float((Decimal(str(amount)) * (1 + Decimal(str(rate)))).quantize(Decimal("0.01")))
        if abs(new - old) >= 0.01:
            logging.warning("total_mismatch old=%s new=%s", old, new)
    return old

assert total(100.0, 0.10, True) == 110.0

The 0.01 comparison threshold is a value to tune for your currency and contract, not permission to ignore a cent: if the API promises exact cent values, log every unequal cent result. This example uses decimal arithmetic only to make a candidate implementation concrete. It does not establish that the candidate is correct; your application might intentionally round each line, apply discounts in a different order, or use a currency-specific rule.

In the real handler, extract the input-gathering step so both calculations receive the same plain values. Do not let the shadow path issue its own SQL queries, because different reads could produce a false discrepancy and extra load. Do not run it if the proposed function sends messages or writes rows; shadow execution is safe only when the new path is free of side effects. An environment setting such as ORDERS_QUOTE_COMPARE=true can enable comparison without changing which value the handler returns.

Log a request correlation ID and a discrepancy category, not raw customer terms. OpenTelemetry’s trace context can help connect the comparison to the existing request trace, while Prometheus can count comparisons and mismatches for a Grafana dashboard. A tentative rollout setting might sample 1% of eligible requests at first; raise or lower that fraction based on CPU cost and how often the edge cases occur. A mismatch rate without a denominator is misleading, so count compared requests as well as mismatches.

The new path earns traffic only after differences have names

Review each discrepancy before switching responses. Classify it as an intentional correction, a defect in the proposed function, a nondeterministic input, or an unresolved legacy rule. Attach a test fixture to every resolved category. This is where I would treat Application Refactoring Strategies for Legacy Software as a menu rather than an order of operations, because extraction, wrapping, and replacement have different risks when a quote can differ by one cent.

Once the unresolved category is empty for the inputs you intend to serve, add a separate flag such as ORDERS_QUOTE_V2. Keep comparison and replacement independent: otherwise turning on observation may accidentally change a customer response. Route an initial cohort through the new function using a stable hash of an internal account ID, so the same account does not alternate between rounding rules on successive requests. Hashing is for consistent assignment here, not anonymization; avoid putting the ID in metrics labels because that would create high-cardinality data.

Choose a rollback condition before the cohort goes live. For example, a team could provisionally stop a 5% rollout if its own measured quote-error rate rises above 0.2% over a 30-minute window. Those are proposed operating limits, not vendor guarantees: adjust them to normal error rates and request volume before enabling the flag. Watch response errors, latency, and mismatched totals separately. A new calculation can be correct yet slower because it triggers repeated conversions, and a fast calculation can still return the wrong amount.

Rollback should mean changing the response path back to the old function without reversing a database migration. That is why this first slice should avoid schema changes if the calculation can be expressed using existing fields. After the new path has handled the intended cohort and the tests reflect accepted behavior, remove the shadow calculation and its flag in a separate change. Leaving both indefinitely costs maintainers two definitions of “correct” and makes future edits harder to review.

Your first commit should make one quote reproducible

Pick one real, redacted quote whose inputs and result you can explain. Add an endpoint test that recreates it, then record the commit and database fixture used to run the test. Do not extract the calculation in that commit. Once the test passes against unchanged code, trace its inputs to a side-effect-free seam; you will have a specific behavior to protect when the refactor begins.

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.