Software Architecture · Legacy Systems

Proving a Legacy Component Is Unused Before Deleting It

Why an absence of observed traffic is not evidence of absence, how deliberate denial turns a guess into a measurement, and what must be checked before code and data are removed for good.

A legacy component is retired when it is deleted, not when it stops being routed to. The gap between those two states is where migrations stall for years: the old service is still deployed, still patched, still consuming a database licence, because nobody can prove that nothing calls it. The proof is the hard part, and it is not the same as observing that no calls arrived last month.

Absence of traffic has many causes other than absence of callers: the observation window missed a monthly job, the caller was down, the metric was not collected at the right layer, or the traffic arrived at an address nobody was watching. Deleting on that evidence produces an outage that is difficult to explain, because the caller that breaks was invisible by construction. Deliberate denial replaces the inference with a measurement.

From observation to denial

Dependency mapping produces a claim: this component has no callers outside the replacement. Denial tests the claim by making the component fail and seeing who complains.

The sequence has four stages, and each narrows the uncertainty.

Instrument the boundary. Before denying anything, ensure that every access path is observable. That means the network path (who connects), the application path (which endpoints are called), and the data path (which tables or files are read). A component with three access paths and two instruments is not covered.

Observe for a complete cycle. The window must cover the longest periodic use, which for financial, regulatory, or seasonal systems can be a quarter or a year. The record must include the last-seen time per caller, not just a total count, because a caller seen once a quarter looks identical to a dead one in a monthly aggregate.

Deny access. Remove the route, revoke the credentials, block the port, or return an error. The choice of error matters: a connection refused or a TCP reset is unambiguous to an operator reading logs, while a timeout is ambiguous and encourages retries. At the HTTP layer, 410 Gone states that the resource existed and will not return, which is more informative to a client maintainer than 404 Not Found, and 503 Service Unavailable with a Retry-After header is the wrong signal because it invites retry traffic to a resource that is being removed.

Observe the failures. Every denied request is evidence of a caller. The denial period is the measurement, and it should run long enough that a caller which only fails on a retry schedule, or only at month end, has time to appear.

What denial does not catch

Denial catches callers at the boundary. It does not catch four other kinds of dependency.

Direct database access. Another application connecting to the same schema does not pass through the component at all, so denying the component’s endpoint does not affect it. Detecting these callers requires the database-side observation described in runtime dependency mapping: connection counts per application name, per client address, and query statistics per table.

Shared credentials. If two systems authenticate as the same principal, revoking that principal denies both, and the resulting failure does not identify which caller was legitimate. Credentials should be per-caller before decommissioning begins, which is often a project in itself.

Replication and subscriptions. A read replica, a logical replication slot, or a downstream subscription can depend on a database that the component owns. Denying the component’s API has no effect on them, and dropping the database breaks them without warning. These dependencies are found by enumerating replication configuration on both sides, not by watching traffic.

Cached and deferred work. A client that has cached a response, or a job that queued work before the denial, will act after it. DNS is the clearest case: removing a name does not stop traffic until every resolver’s cached record expires, and a time-to-live of 86400 seconds keeps the name resolvable for a day. Long-lived connection pools extend the effect beyond the TTL, and a client that resolves the name once at startup may never look it up again.

Reading the failure signal

Not every denied request is a caller that matters. Scanners, monitoring probes, and health checks generate traffic that does not represent a business dependency, and a decommissioning that stops at the first alert never finishes.

Classification needs the request’s identity, not the self-declared client name. The useful attributes are the authenticated principal, the source address, and the endpoint. A principal that belongs to an internal monitoring system, calling a health endpoint, at a regular interval, is distinguishable from a business caller by its identity and its endpoint, even if both produce identical error logs.

The signal also needs a destination. Denied requests must go to a queue with an owner and a response time, or they become noise. The common failure is a denial period in which nobody watches, followed by a deletion that assumes silence, when in fact the errors were being dropped by a log collection path whose retention was shorter than the denial period.

Retention of the denial evidence deserves its own rule. If the denial period is a year, the logs from its first month must survive a year, which usually means they are not in the same store as application logs with thirty-day retention. Aggregate first: a daily count per principal and endpoint, with the last-seen time, is small enough to keep indefinitely and answers the only question that matters at deletion time.

Deleting the code and the data

Code removal is the easy part once the traffic is gone, and it still has structure. The entry points go first: routes, message handlers, scheduled jobs, exported symbols. Then the internal modules, checked against a build that no longer references them. Feature flags that gate the old path are removed rather than set to false, because a flag left in the code preserves the old branch and the old tests. Dependency declarations are pruned, since an unused library remains a supply-chain exposure and a reason the build cannot be upgraded.

Data removal is irreversible in a way code removal is not, and it is where decommissioning needs a written decision rather than a technical one. The questions are: what retention obligation applies, what must be archived and in what format, how long a restore must remain possible, and who signs off. An export to a documented, readable format is not the same as a database backup: a backup is only restorable with the same engine version and configuration, which will not exist after the decommissioning.

Deleting rows is also a physical operation with consequences. A large delete generates write-ahead log at a rate proportional to the rows removed, holds locks, and leaves bloat that requires vacuum, which is why truncation or partition drops are usually preferred where the schema allows. Dropping a partition is a metadata operation; deleting its rows is a scan.

The final state

Decommissioning is complete when the component cannot be reached, cannot be started, and no longer holds data that anyone needs. Each of those is checkable, and the checks are worth writing down as the exit criteria: no routes reference the service, no deployment manifest exists, no credentials remain valid, no replication slot points at its database, and the archived data has a documented location and owner.

There is one more check that is often skipped: the monitoring and alerting that referenced the component. A retired service whose dashboards and alerts remain produces a stream of false alarms, and the usual response is to silence them, which is how the remaining real alerts get silenced too. Dashboards, alert rules, runbooks, and on-call rotations are part of the component.

The full arc of a migration ends here, and it began with the opposite problem: a system nobody could safely change. The seams made it observable, the dependency map made its callers known, and the routing layer let it be replaced while it kept running. Retiring it is what converts a migration from a permanent state into a finished one.

References

  1. RFC 9110: HTTP Semantics — IETF
  2. Working Effectively with Legacy Code — Michael Feathers, Prentice Hall, 2004
  3. Site Reliability Engineering — Beyer, Jones, Petoff, and Murphy, O'Reilly, 2016