All articles
12 September 2026
7 min read

Removing features nobody uses any more

By Tim Rutte, Cloud & Software ArchitectTopicLegacy & Modernization

A panel of twelve toggle switches, dust settled around several of them, one switch blue.

Every grown system carries a pile of functionality nobody uses any more. The export in the format of a customer who left in 2019. The second sign-in route from before the migration. The report somebody in finance asked for once, generated every night since.

These things are not free. They cost in every upgrade, because they get tested along. They cost in every migration, because their tables come along. They cost in every security review, in every onboarding, and in every question of whether a particular place may be touched.

Deleting is the most underrated form of modernization, and it is the only one that makes the system smaller rather than larger. This article is about doing it without turning it into a gamble.

Why it does not happen anyway

The reason is not technical, and until it is said out loud, no procedure helps against it.

The risk is distributed one-sidedly. Remove a feature somebody did need and you made a mistake, with your name on it. Keep one nobody needs and you did nothing wrong; the cost spreads across everyone and across years, and nobody can attribute it to a decision.

In that situation keeping is the rational choice for every individual, and the result is a system that only grows. So the countermeasure is not an exhortation but a procedure in which deleting does not feel like a gamble: measure, announce, switch off, wait, delete. The step everything hangs on is the fourth.

Measuring instead of guessing

"Nobody uses that any more, surely" is not a basis. There are three levels on which usage can actually be established, and they answer different questions.

The access level is the cheapest. The web server logs answer, for every address, when it was last requested, which covers everything that has a page or an endpoint of its own.

The data level answers the question for everything that writes. A look at the newest row per table sorts the living from the standing in ten minutes:

-- When was each of these tables last written to? Anything two
-- years old here belongs on the list.
SELECT 'export_job' AS table_name, max(created_at) FROM export_job
UNION ALL
SELECT 'fax_dispatch', max(created_at) FROM fax_dispatch
UNION ALL
SELECT 'legacy_report', max(created_at) FROM legacy_report
ORDER BY 2 NULLS FIRST;

The code level covers everything that has neither an address nor a table: the method that may or may not still be called from a batch job. There is an old and very effective trick for that, a tombstone: a line that reports somebody came past here.

public function oldDiscountTier(Order $order): Discount
{
    // Tombstone, placed on 2 Nov 2026. If nobody comes past here
    // before February, the method and its table go.
    // Warning level, not info: nobody sees an info line.
    $this->logger->warning('tombstone', [
        'place'  => __METHOD__,
        'caller' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function'] ?? '?',
        'since'  => '2026-11-02',
    ]);

    return $this->calculateOld($order);
}

The caller in the log line is the important part. The information "was called" leads to a discussion; the information "was called by this one nightly report" leads to a decision.

The waiting period everybody gets wrong

The step this procedure stands or falls on is the observation period after the tombstone. The usual choice is two to four weeks, and it is wrong for an entire class of features.

Plenty of things in a business system do not run daily. The annual close runs once a year. The stocktake once a year. The report to the trade association once a year, in February. A feature that has gone unused for four weeks may still be the one without which nobody can file that report next February.

The rule that follows is uncomfortable and saves the one genuinely expensive mistake: the observation period follows the rhythm of the thing, not the rhythm of the project. For anything that sounds like a close, a filing, a stocktake, a year end or a season, it is thirteen months. For the rest, four to eight weeks are enough.

Anyone unwilling to wait thirteen months does not have to guess: a look at the data of the last three years shows whether this feature ever ran in February.

The switch for this is the same one used to introduce a feature: Feature flags in a monolith without framework support.

Switching off is not deleting

Between "we think nobody needs this" and "it is gone" belongs a state in which the feature is no longer reachable but is back in a minute.

The switch for that is a feature flag, and the sequence is always the same: announcement to users with a date, then the switch-off, then the waiting period above. During that time either somebody speaks up, and then you have a requirement instead of a guess, or nobody does, and then deleting is no longer a decision but a piece of tidying.

The announcement is not a formality. It is the difference between an outage perceived as an outage and a shutdown perceived as a shutdown. Same event, two very different conversations.

What goes with it, and why half-deleted is worse

A feature is more than its code, and a half-removed feature is worse than one still fully there: it looks like it exists and does nothing.

Going with it: the tables and their foreign keys, the nightly job that filled them, the permissions that existed only for it, the configuration values, the translations, the menu entries, the monitoring now watching a metric that is always zero, and the section in the manual.

The monitoring gets forgotten most often and takes the nastiest revenge: an alert that never fires again, or worse, one that now fires every night because a value is missing that nobody produces any more. Two weeks later somebody mutes the alert, and three months later the same channel is deaf for a real incident.

In practice that means: the list of what goes with it comes out of the measuring, not out of the deleting. Look for the tables only after the code is gone and you will not find them.

The data outlives the feature

One distinction saves trouble: removing a feature is not a decision about its data.

Part of that data carries a retention obligation that has nothing to do with whether the application can still display it. Invoices, bookings and everything attached to them stay for ten years, even when the route to them is deleted.

So the procedure splits in two: the code goes, the data goes into an archive that stays readable without the application being needed for it. A file with a description next to it is enough, and it is worth more in a year than a table left in the system that nobody knows how to interpret.

The opposite mistake happens just as often: keeping data for which there is neither an obligation nor a purpose is not caution, it is processing without a basis.

The same exercise for caches rather than features is covered in The cache as a debt.

How success gets measured

Finally the question of what this tidying actually buys, and the obvious answer is the wrong one. Lines removed is not a metric; it says nothing about whether the system is easier to change afterwards.

What does say something is three other numbers, and all three move immediately: the runtime of the test suite, the number of tables that have to come along in the next migration, and the number of places a language upgrade touches.

The side effect is larger than all three. In a system from which things get removed regularly, the team knows what is used, because they looked it up a few times. That is a different kind of system from one where only additions have happened for years, and the difference shows up in every modernization that comes after.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.