An untested legacy app can look safe behind a new proxy until a scheduled job writes to the same tables. For the DevOps engineer carrying the pager, the first decision is not where to put a seam; it is whether that seam can contain the app’s side effects. I would wrap first only when traffic and writes have a controllable boundary. Otherwise, I would refactor one write path before moving production traffic.
A wrapper wins only when it sees every consequential call
Edge wrapping means putting a proxy or adapter in front of the existing app while leaving its internals in place. It wins when requests enter through a known endpoint, the app owns its state changes, and the old route can remain available during a cutover. Its cost is another production component to configure, monitor, and roll back.
Your First Legacy Upgrade Needs a Seam, Not a Rewrite gets the scope right, but an HTTP seam cannot contain a batch writer that connects directly to the database. Before deploying a wrapper, inventory ingress from the load balancer, internal callers, scheduled jobs, message consumers, and operator scripts. For each path, record whether it reads, writes, or triggers another writer. A diagram based only on web traffic is insufficient because the bypasses are often the paths that make rollback unsafe.
Start with observation rather than route changes. In a Docker network where legacy resolves to the existing app on port 8081, this NGINX server block can carry the current route while adding a request identifier and a response marker:
server {
listen 8080;
location /orders/ {
proxy_pass http://legacy:8081;
proxy_connect_timeout 2s;
proxy_read_timeout 10s;
proxy_set_header X-Request-ID $request_id;
add_header X-Decision-Route "legacy" always;
}
}
Put the block in a file loaded inside NGINX’s http context and run nginx -t before reloading. The 2-second connect timeout is an initial value to tune against observed connection latency; the 10-second read timeout is a separate operational choice that must allow legitimate slow responses. NGINX documents 60 seconds as the default for proxy_read_timeout, so copying that default without checking upstream behavior can leave failed requests occupying connections longer than this service can afford.
Use curl with –max-time 12 to probe a known read endpoint through both routes, then compare status codes, response headers, and bodies. Preserve W3C Trace Context’s traceparent header where callers already send it, and add OpenTelemetry instrumentation only where it produces trace links you can verify. The wrapper wins this round if the route marker, logs, and traces account for requests without changing their outcomes; a proxy that cannot explain which backend answered a request is a poor cutover control.
A narrow refactor wins when state escapes the perimeter
Internal write-path refactoring means changing a small part of the app so all relevant writers use one enforceable operation or transaction boundary. It wins when a job, consumer, or direct database client bypasses the proposed wrapper, because routing HTTP requests cannot govern writes it never receives. Its cost is touching poorly tested code and accepting a slower first release while QA captures its behavior.
Refactor or wrap first, when QA owns an untested legacy app makes test ownership part of the choice, but I would let the write-path inventory decide the production order because tests around a proxy cannot constrain an unseen writer. Ask QA to capture specific order transitions, failure responses, and retry behavior before altering the shared operation. Those examples can become pytest characterization tests; they are evidence about current behavior, not proof that every legacy behavior is desirable.
Suppose the web app and a nightly importer both update an order’s status in PostgreSQL. Moving only the web app behind NGINX creates two ways to perform the same transition, so the wrapper cannot reliably enforce validation or idempotency. A narrower change is to make both callers invoke the same application operation or a carefully reviewed database function, with the status update and its event record committed in one transaction. That refactor wins only if callers can be moved without an extended dual-write period; otherwise, its apparent simplicity hides reconciliation work.
Use evidence from the database, but distinguish workload statistics from attribution. PostgreSQL’s pg_stat_statements, with pg_stat_statements.track = all, can help identify frequent statement shapes; it does not by itself tell you which job made a particular write. Set distinct application_name values for the web app and importer, and consider pgAudit write logging for a bounded investigation, because full write logs can impose storage and review costs. Match findings to deployment schedules and message-consumer logs before declaring a path unused.
I would not put a new public API in front of the web app and call the order operation isolated, because the importer would still be able to change its state underneath that API. I also would not turn on an unbounded database audit stream merely to avoid inventory work, because the resulting log volume could create a second production problem without identifying the responsible caller clearly.
Rollback is a state decision, not a routing switch
The comparison is explicit: edge wrapping wins for contained traffic and reversible behavior, at the cost of proxy operations and an added network hop; internal write-path refactoring wins for shared mutable state and bypassing writers, at the cost of code changes, characterization tests, and a harder deployment. Neither wins because it is newer. The useful question is which option leaves you able to stop a bad change without corrupting the next attempt.
A route rollback can send the next request to the old app, but it cannot undo an order status already written by the new path. For a read-only endpoint, switching back may be enough because neither route changes business state. For a mutating endpoint, rehearse what happens after a request succeeds, the client times out, and the client retries against the old route. If both routes can execute the write, require an agreed idempotency mechanism and verify it against the actual database state. An Idempotency-Key header alone offers no protection when the old app ignores it.
Define rollback in terms an operator can execute. Record the route change, the last accepted write, the location of queued work, and the condition under which the old route may resume writes. If rollback requires replaying events, name the replay owner and test a duplicate; if it requires a database restore, acknowledge that writes made after the restore point need a separate decision. A “flip the flag back” runbook is incomplete when both versions have modified shared rows.
Budget deployment time for connection draining as well as application shutdown. Kubernetes publishes a default terminationGracePeriodSeconds of 30 seconds; check that value against observed request duration and worker shutdown behavior rather than assuming it is sufficient. Prometheus request-duration histograms and Grafana panels can show whether requests remain in flight during a drain, while queue-depth metrics show whether a stopped consumer has left work behind. Those observations matter because a clean HTTP cutover does not establish that asynchronous work stopped cleanly.
A small production trial should try to disprove the choice
Before shifting traffic, write down what would make the chosen approach fail. For wrapping, that may be an unaccounted writer or a request whose identity disappears between proxy and app. For refactoring, it may be a changed error response or a retry that now performs the write twice. These are useful trial conditions because each has an observable result and a specific owner who can stop the rollout.
For an illustrative baseline, suppose a full day of Prometheus data shows a measured p99 order-read latency of 420 ms. Treat that figure as an example to replace with your own measurement, not a target to inherit. A proposed first exposure of 1% of eligible requests for 30 minutes is likewise a rollout budget to adjust for request volume: a low-volume endpoint may need a longer window to produce enough comparable requests. Compare the candidate route with the existing route over the same period, and inspect errors by route marker so an aggregate graph cannot hide a small, failing cohort.
Keep the first trial on reads if reads are independently routable, because they let you test proxy behavior without making rollback a data-repair exercise. Check access logs for unexpected POST or PATCH requests at the trial location. If a mutating request reaches it, stop and classify that path before broadening the route; silently forwarding it provides no evidence that its write is safe. For an internal refactor, use QA’s captured cases and a staging replay of sanitized requests, then inspect resulting database rows and emitted events rather than trusting HTTP status alone.
Make the stop condition executable by the on-call engineer. Alert on route-specific 5xx responses, unexpected increases in request duration, queue growth, and duplicate-write signals; keep the old route deployable until those signals remain stable through the trial. Record the NGINX configuration revision, app revision, database migration state, and the command or control that stops new traffic. A dashboard without a tested stop action helps diagnose a failure, but it does not shorten the failure.
The first move is to find the writer you cannot route
Before approving a proxy or a code change, take one consequential state transition and list every caller that can perform it. Confirm the list against application logs, job schedules, and database evidence, then ask QA for one failing or ambiguous retry case. If every writer can cross a reversible boundary, trial the wrapper on reads. If one cannot, contain that writer first; your rollback depends on it.


