跳到正文
DEV Community· Andrii B.·· 3 小時前AI 評分47

犧牲式架構:打造一開始就計劃淘汰的服務

Sacrificial Architecture: Building the Service You Already Plan to Delete

AI 導讀

犧牲式架構主張,團隊應有意把部分服務設計成可替換的暫時方案,而非假設所有服務都要永久維持。eBay 曾先後以 Perl、C++ 和 Java 重建系統;Google 的指引是按約 10x 的現有增長設計,並在達到 100x 前計劃重寫。服務應以穩定介面隔離實作,讓替換可逐步進行,同時保留核心領域模型及數據。

正文

The most expensive service in your system isn't the one you'll maintain for ten years. It's the one you'll maintain for ten years by accident, because you built it to last, never planned its funeral, and by the time it's clearly wrong to keep, it's load-bearing for six other things and nobody can afford to touch it.

There's a name for the alternative, and it's older than most of us think. Martin Fowler called it sacrificial architecture: deliberately accepting, right now, that the code you're writing will be thrown away in a few years. In his words, "often the best code you can write now is code you'll discard in a couple of years time." Fred Brooks got there decades earlier in The Mythical Man-Month with the line everyone quotes and nobody acts on: "plan to throw one away; you will, anyhow."

The idea is not new. What's rare is doing it on purpose. Almost every team builds every service as if it's forever, then acts shocked when forever turns out to be eighteen months. Sacrificial architecture flips that: some services you build knowing they're temporary, and the whole trick is building them so that "temporary" is a decision you can actually execute, not a lie you tell yourself.

The case for building things to die

Start with why this isn't just laziness dressed up in a nice name.

The canonical example is eBay. It started as Perl scripts in 1995, was rebuilt in C++ around 1997, and rewritten again in Java around 2002. Those weren't failures. As Fowler puts it, "much of that success was built on the discarded software of the 90's." The 1996 architecture couldn't have handled 2006's load, and the 2006 architecture would have been absurd over-engineering for 1996, too complex and expensive to build for a business that didn't exist yet. Each version was right for its moment and wrong for the next one, and the mistake would have been trying to build the forever-version on day one.

Google made the same idea a rule of thumb. Jeff Dean's guidance is to design for roughly 10x your current growth, but plan to rewrite before you hit 100x. Not "build for infinity." Build for the next order of magnitude, and assume the one after that gets a rewrite. Subsystems get redesigned and discarded on purpose.

The reframe is simple and it's freeing: you are almost never building the final version of anything. You're building the version that gets you to the next version. Once you accept that, the interesting question stops being "how do I make this last" and becomes "how do I make this cheap to replace when it stops fitting."

What to sacrifice, and what you must not

Here's where the idea goes wrong in practice, and where the senior judgment actually lives. "Sacrificial" is not a property of your whole system. It's a decision you make per component, and getting the split right is the entire game.

Sacrifice the parts facing the most uncertainty. The integration with a partner you might drop next quarter. The first version of a feature nobody's sure will land. The service sized for a scale you're guessing at. Anything built on an assumption you don't trust yet is a good candidate to build cheap and plan to replace, because the odds you got it right the first time are low and the cost of being locked in is high.

Keep durable the things that genuinely outlive code. Two of them, especially. The first is your core domain model, the concepts your whole business is built on, because those change slowly and everything else references them. The second, and this is the one teams botch, is your data. Code is disposable; data is not. A customer's order history has to survive three rewrites of the service that manages it. So the hard rule of sacrificial architecture is that a service you plan to delete must not own the durable data in a way that dies with it. The data lives outside, in a store with its own lifecycle, and the sacrificial service is just the current, replaceable set of behavior on top of it.

What to sacrifice, what to keep: an amber column to sacrifice (unproven feature v1, a partner integration you might drop, a service sized for a guessed scale, code built on an assumption you don't trust yet) next to a green column to keep durable (core domain model, the data such as order history and records, contracts other teams depend on, anything referenced everywhere), with a note that data outlives code and a sacrificial service must never own the durable data

That warning has a corollary that surprises people: sacrificial does not mean sloppy. It's tempting to hear "we'll delete this anyway" as permission to skip tests and cut corners. Fowler is blunt that this is a mistake, poor internal quality "will bite you more rapidly than the replacement time." A service you plan to delete in two years still has to work well for two years, and the bad version will slow you down long before you get to retire it. The only quality you're allowed to sacrifice is polish on code that's already on its way out the door this quarter. Everything with a runway ahead of it earns the same care as permanent code. What makes it sacrificial isn't lower quality. It's a planned exit.

The seam is what lets you delete it

So what actually makes a service deletable? Not intentions. A boundary.

The reason "temporary" services calcify into permanent ones is almost always the same: something reached past the service's interface and coupled to its guts. Another team started reading its database directly. A shared library leaked its internal types everywhere. Once that happens, you can't delete the service without breaking things you can't even fully enumerate, and the sacrifice is off the table.

The defense is a hard boundary and nothing crossing it but a stable contract. Put the sacrificial implementation behind a port that describes what it does, never how. Then a replacement is just a second implementation of the same port, and you can run them side by side and cut over gradually, the strangler-fig move, instead of a big-bang rewrite.

// The port is the durable thing. The implementation behind it is sacrificial.
interface RiskScorer {
  score(tx: Transaction): Promise<RiskDecision>;
}

// v1: a cheap heuristic you built in a week, fully expecting to bin it.
class HeuristicRiskScorer implements RiskScorer { /* rules, thresholds */ }

// v2: the ML service you'll build once you actually understand the problem.
class ModelRiskScorer implements RiskScorer { /* calls the model service */ }

// Callers depend on RiskScorer, never on which one is wired in.
// Swapping v1 for v2 is a config change and a gradual traffic shift, not a rewrite.

Notice what this buys you. Callers never know which implementation they got, so replacing the sacrificial one touches nothing but the wiring. The data the scorer needs lives in its own store, not inside HeuristicRiskScorer, so deleting v1 deletes behavior, not history. This is Fowler's other point: "good modularity is a vital part of a healthy code base, and modularity is usually a big help when replacing a system." The boundary isn't bureaucracy. It's the thing that keeps your future sacrifice actually possible.

The seam is what lets you delete it: web app, mobile app, another service and a batch job all call the RiskScorer port, a stable contract; behind it a gradual traffic-shift toggle routes between the sacrificial v1 HeuristicRiskScorer and the v2 ModelRiskScorer replacement, while the data store sits outside both implementations

Why "temporary" becomes permanent anyway

Here's the part that's actually hard, and it has nothing to do with code. Most sacrificial services never get sacrificed, and it's worth being honest about why, because every reason is a thing you can prevent.

Nobody budgeted the deletion. Building the replacement gets a project and a deadline. Deleting the old thing gets... nothing. It's not a feature, no customer asked for it, so it sits on a backlog forever while the "temporary" service quietly turns twenty. If the teardown isn't a scheduled, owned piece of work, it will not happen.

The boundary got breached. We just covered this. One direct database read, one leaked internal type, and the service is welded in place.

It became load-bearing by accident. The prototype that was "just for the demo" ends up running payroll, because it worked, and working software has a way of attracting dependents. The longer a sacrificial service lives without being deleted, the more things lean on it, which makes it harder to delete, which makes it live longer. That loop is how every legacy system was born.

There were no exit criteria. "We'll replace this later" is not a plan; it's a feeling. "We delete this when we cross 50k transactions a day, or when the partner deal closes, or by Q3" is a plan. Write the trigger down when you build the thing, while you still remember why it's temporary, because in a year the context is gone and all that's left is a service everyone's afraid to touch.

How 'temporary' turns permanent: four forces (no one budgeted the deletion, a system reached past the boundary, it quietly became load-bearing, no written exit criteria) pull on a temporary service that grows more entangled from month 1 to year 5 until it becomes a legacy system nobody can touch

Building one on purpose

Put it together and a service you actually plan to delete has five things, none of which is "worse code":

  1. A hard boundary. Everything goes through its contract; nothing reads its internals or its database directly. This is what keeps deletion possible.
  2. Its durable data on the outside. The service owns behavior, not the records that must outlive it. Deleting it loses code, never data.
  3. A written exit criterion. A concrete trigger, a metric or a date, decided at build time and put somewhere the next person will find it.
  4. An owner for the teardown. Someone accountable for pulling the plug when the trigger fires, with the deletion treated as real, scheduled work, not backlog lint.
  5. Real quality for its actual lifespan. Tested, observable, honest. The only thing you skip is polish on code that's leaving this quarter.

Do that and sacrifice becomes a tool instead of a regret. You get to move fast on the uncertain parts precisely because you've made them cheap to undo, which is the opposite of the usual trap where speed now means a mess you're stuck with later.

The honest close

Sacrificial architecture gets misread in two directions, and both are wrong. It's not an excuse to write throwaway garbage, because the throwaway part still has to carry real load for real years before it's thrown away. And it's not a clever way to avoid commitment, because the commitment just moves: instead of committing to keep something forever, you commit to delete it on a trigger, and follow through.

The failure mode was never building something you'll delete. Half your systems should be things you'll delete. The failure is building something you'll delete and then not deleting it, letting it harden into the exact permanent, untouchable, load-bearing legacy you were trying to avoid. Design the death when you design the thing. Then, when the trigger fires, actually hold the funeral.


Originally published at andriiboyko.com.

來源:DEV Community · dev.to