Designing for deletion

A few months ago my team had to kill a feature. Nothing dramatic; a half-finished onboarding flow product had quietly stopped believing in. The ticket said “remove the new welcome wizard.” I estimated two days. It took three weeks.

The wizard itself was about 400 lines. The reason it took three weeks is that those 400 lines had grown roots. A flag on the user record. A column in the analytics table. Three branches in the auth middleware. A template partial pulled into the main layout. A background job that “would be useful for other things later.” A helper in internal/util that nine packages now imported. By the time we’d traced every tendril the diff touched 60 files and the review thread looked like a hostage negotiation.

That’s when I started thinking about deletion as a first-class design concern. Most of the architecture advice I’d absorbed was about making things easy to add: extensibility, plugin points, composition. Almost nothing about making things easy to remove. And yet removal is just as common: experiments end, strategies pivot, features outlive their usefulness and sit there because deletion is too scary or expensive.

So I’ve come to believe the systems I want to work in are not the ones easiest to add to. They’re the ones easiest to remove from.

Symptoms of code that resists deletion

The wreckage from the wizard had a few familiar shapes.

Circular dependencies dressed up as helpfulness. The wizard imported the user service, but the user service had also started importing a constant from the wizard (“the default starting tier”). Removing the wizard meant moving the constant somewhere neutral first; a separate PR, a separate review, a week.

Convenience reach-throughs. A handler that should talk to a billing interface is instead calling billing.Stripe.Charge directly, because that’s where the call lived. Multiply by a hundred handlers and the billing package is no longer a module; it’s a public surface every binary has memorised.

Shared helper libraries. Every codebase grows one. It starts as pkg/util, gets renamed to internal/common, ends up as lib/. By year two half the codebase imports it for one function each. The opposite of modularity; a load-bearing pile.

Leaky abstractions. An interface that takes a *sql.DB, a “generic” cache that exposes Redis-specific options, a logger that lets you reach in for the file handle. A wrapper in name only; you can’t remove it without breaking every caller.

None of these looked wrong when written. Each was a single line, a single PR, a sensible reviewer comment (“eh, ship it”).

The dependency-direction rule

The habit that has paid back the most is being aggressive about single-direction dependencies. Higher layers import lower layers. Lower layers never reach upward. No exceptions. If two packages need to talk both ways, they’re one package, or there’s a third package they should both depend on.

The picture I keep in my head:

       ┌──────────────┐
       │   handlers   │   (HTTP, gRPC, CLI: thin)
       └──────┬───────┘
              │ imports
              ▼
       ┌──────────────┐
       │   services   │   (use-cases, orchestration)
       └──────┬───────┘
              │ imports
              ▼
       ┌──────────────┐
       │    domain    │   (pure types, no I/O)
       └──────────────┘
              ▲
              │ implements
       ┌──────┴───────┐
       │   adapters   │   (db, queue, http clients)
       └──────────────┘

The arrows never reverse. domain does not know a database exists. services knows there’s a “thing that stores users” but not which thing. handlers never reach into adapters. Adapters depend inward, implementing interfaces the domain defines for its own convenience.

The payoff for deletion is enormous. To remove the wizard you look at what depends on its service, not at what it uses. Imports flow one way, so the blast radius is bounded by the layer above and nothing else. You can grep for it.

Anti-corruption layers, religiously

Every place a system touches something it doesn’t own (an external API, a third-party SDK, a vendor’s data model) is a place where foreign concepts can leak in. Translate at the edge. Pay the small tax of an extra type and function, and keep the rest of the code ignorant of the vendor’s quirks.

The first time you switch payment providers, the layer pays for itself ten times over. The third time, when product decides the whole experiment was a mistake, you delete the adapter and its interface in a single PR.

The rule I now say out loud in design reviews: “if a vendor’s struct shows up in our domain code, that’s a bug, not a shortcut.”

One reason to change, per package

I used to read “single responsibility” as a class-level rule and shrug at it. It’s more useful as a package-level rule: each package should have one reason to change. If product changes, billing shouldn’t have to. If we swap databases, the domain shouldn’t have to. If the HTTP router cuts a new major version, only the handlers move.

When a package has two reasons to change, deletion becomes coupled. Killing the feature touches the code that supports it and the code that incidentally lives next to it, and now you’re back in 60-file PR land.

Feature flags with an expiry date

A small habit that saves real time: every flag we add now has an expiry in the comment above it.

// expires: 2022-Q1
// Owner: payments. After expiry, default to true and delete this branch.
const useNewCheckout = false

We don’t enforce the expiry with tooling. The comment alone has been enough. Half-finished experiments used to drift for a year because nobody had explicit permission to delete them; now they have a date on the headstone. The same trick works on TODOs and “we’ll clean this up later” branches. Write down when later is.

Reach-through versus seam

A checkout handler I dealt with last year looked like this:

func CheckoutHandler(w http.ResponseWriter, r *http.Request) {
    userID := r.Header.Get("X-User")
    cart, err := db.LoadCart(userID)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    total := billing.SumLineItems(cart.Items)
    if billing.IsTaxable(cart.Region) {
        total += billing.ComputeTax(total, cart.Region)
    }
    receipt, err := billing.Stripe.Charge(userID, total)
    if err != nil {
        http.Error(w, "payment failed", 502)
        return
    }
    _ = db.SaveReceipt(userID, receipt)
    w.WriteHeader(http.StatusOK)
}

Four packages named. Three reasons to change. Swapping Stripe for someone else meant finding every handler that looked like this and rewriting it. We had eleven.

Compare with the seam version:

type Checkout interface {
    Checkout(ctx context.Context, userID string) error
}

type checkoutAPI struct{ billing Checkout }

func (h *checkoutAPI) Handle(w http.ResponseWriter, r *http.Request) {
    if err := h.billing.Checkout(r.Context(), r.Header.Get("X-User")); err != nil {
        http.Error(w, "checkout failed", 502)
        return
    }
    w.WriteHeader(http.StatusOK)
}

Same behaviour, one verb. The HTTP layer no longer knows billing exists, or that there’s a database, or that Stripe is the vendor. To delete the feature, delete the Checkout implementation and the route. To swap vendors, write a new Checkout. The handler doesn’t move.

The second version is more code on day one. The argument for designing this way is that day one is not when the bill comes due.

The cultural side

A habit I’ve come to value almost as much as any of these rules: celebrating PRs that only delete code. Not “refactor,” not “consolidate.” Pure deletion. Lines removed, nothing added.

These are the highest-leverage PRs a team produces and they are chronically under-rewarded, because they look like nothing happened. You can’t demo them at sprint review. But every deleted line will not break, will not need updating when the framework changes, will not confuse the next person who joins. Deletion compounds the way addition does, in the opposite direction.

A ritual that helps: a recurring “graveyard” review, maybe once a quarter, where we hunt for dead flags, dead endpoints, dead config keys, dead helpers, and take them out. No refactoring in the same PR. The diffs are usually the most boring of the week, and they make the codebase noticeably lighter.

Closing thought

The wizard, in the end, came out clean. The next time we killed a feature it took two days, like the estimate said. The difference was not better code; it was a quarter spent quietly drawing the dependency arrows the right way, putting interfaces in front of vendors, and letting the helper library stop growing.

What I tell people now, when they ask how to architect a young codebase: don’t optimise for ease of addition. Things will get added either way; that’s what writing software is. Optimise for ease of removal. At every seam, ask “if this feature dies tomorrow, where will the wound be?” Then design the seam so the wound is small.

If you can’t delete it cheaply, you don’t really own it. The codebase owns you.

comments powered by Disqus