25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

25KB

FOCUS Architecture — Chapter-20: Migrate Legacy Code Without Stopping the Factory

  • Source: /library/FOCUS Architecture/source-file.pdf
  • PDF pages: 616–639
  • Pages without text: none

Migrate Legacy Code Without Stopping the Factory In this chapter, you’ll: pick the first slice to migrate with a criterion that has a number (frequency of change times pain), not gut feeling; run the four steps of strangling on a real slice: characterization, use cases, adapter, new view; write down, in the open, what you will NOT migrate, covering the four cases where migrating is waste. Chapter 19 ended with a question: what happens when the code was born with all six damages at once, in a ten-year-old legacy that carries the company’s cash register? This chapter answers it. You won’t rewrite anything. You’ll fence today’s behavior with a test that freezes it, pull one slice at a time into FOCUS’s shape, and let the rest of the old system keep running in peace, behind a boundary that lives in a single file. Almost no reader of this book holds a greenfield project: a field built from scratch, with no inherited line of code to respect. The project that pays your salary is probably an old system, written by people who already left the company, with no tests, business rules scattered everywhere. If your case is the rare greenfield, read on anyway: this chapter is the vaccine against the decision that kills the most healthy systems, the full rewrite, and the

investment criterion you’ll use once your greenfield ages. All code turns into legacy; the only open question is who’s on call when it happens. Our patient is Rosie’s Coffee Shop’s tab system, production version: eight-year-old procedural PHP that runs the counter every day. Here’s its heart, the “controller” that closes a tab: PHP function calculate_total(array $items): float { $total = 0.0; foreach ($items as [$name, $price]) { $total += $price; } // Closing rounds the float to 2 places and moves on with its life. return round($total, 2); }

// The “controller”: fetches data, applies the business rule, and // builds the response, all in the same place. function controller_pay(int $table): string { $items = find_tab($table); $total = calculate_total($items); // The card machine speaks cents; the conversion truncates the float. $cents = (int)($total * 100); // Inline business rule: the card machine's limit, hardcoded. if ($cents > 5000) { // A business refusal disguised as an accident. throw new DomainException(“card limit”); }

return “table $table: paid $cents cents”; } You just walked straight out of an anti-pattern catalog; use it. The limit rule inside the controller is entry 1 of chapter 19 (business rule in the orchestrator, except here there’s no orchestrator at all). The payment refusal traveling as a DomainException up to a generic catch at the top is entry 5 (domain try/catch), made worse by the exception crossing every layer, because there are no layers. Prices live in a float, and the conversion to cents truncates whatever the float got wrong. And there isn’t a single test. This code breaks half a dozen rules you know by name, and it still has one virtue your new diagram doesn’t: it has been closing real tabs for eight years. Respect that. This chapter’s goal isn’t to erase this file; it’s to retire it gradually, without Rosie ever noticing. The fig that strangles The pattern has a plant’s name: strangler fig. Martin Fowler coined StranglerFigApplication on his bliki (martinfowler.com, 2004; the post was originally called “StranglerApplication” and got renamed in 2019): a new system grows at the edges of the old one, route by route, until the old one stops receiving calls and can be switched off with no funeral. The analogy comes from the strangler figs Fowler saw in Australia: the seed germinates high on a host tree, roots climb down the outside of the trunk to the ground, and years later the fig stands on its own, shaped like the tree that hosted it. At no point did the forest go without a tree in

that spot. That’s the pattern’s entire promise, and it’s the yardstick for every decision in this chapter: at no point does the coffee shop go without a tab system. FOCUS adapts the pattern with one directional decision: strangle by feature, never by layer. The layer-by-layer alternative looks organized on a slide (“first we migrate all of persistence, then all the services, then the screens”), and it’s a big-bang rewrite wearing a new hat. While the whole layer isn’t ready, nothing works end to end; the value only shows up at the end, which is exactly the flaw strangling exists to avoid. Migrating the whole payment slice (view, orchestrator, use case, repository, a vertical cut from chapter 11) delivers a feature running on the new shape in the first week already, while tab, inventory, and menu keep running on the old PHP, untouched. Look at the diagram: inventory and menu might well die legacy, and that’s fine. Strangling isn’t a purity crusade; it’s an investment, and investments get chosen.

Choose the first slice: frequency times pain Which slice to migrate first? The answer isn’t “the ugliest one.” It’s the one that combines two measures you already have in the repository: how often that piece changes (count commits per area over the last few quarters) and how much it hurts when it breaks. Ugly code nobody touches charges no rent; decent code that changes every week charges compound interest. Rosie’s Coffee Shop board, last quarter’s commits: Feature Commits this quarter Pain when it breaks payment 31 a declined payment brings down the whole tab tab 12 a wrong order noted, customer waits loyalty 9 wrong points, complaint at the counter inventory 6 manual count at closing time menu 2 stale price until someone edits it Payment wins on both axes. That’s 31 commits in a quarter (a change every two business days in code with zero tests), and the pain is the worst on the list: when payment fails, the whole tab jams and the line walks over to the competitor. The first slice is elected, with a number you can defend in a meeting. Run this count on your own system before you write a single line of code; if the commit champion barely hurts when it breaks, choose by pain instead, because commits measure activity and pain measures consequence.

Step 1: fence the behavior with a characterization test Before you move a single line, freeze what exists. A characterization test is a test that documents what the code DOES today, not what it should do; the term comes from Michael Feathers, in Working Effectively with Legacy Code (2004), the same book that defines legacy in the most useful way I know: legacy is code with no tests. Not old code, not ugly code. Code whose behavior nobody can state with confidence, and characterization exists to turn that ignorance into a contract. The detail that separates a characterization test from an ordinary one: if the legacy has a bug, the test expects the bug. Rosie’s system has one, and it’s a good one. Table 7’s tab has a $7.00 espresso and a $9.90 slice of cake. Add it up in your head: $16.90, or 1690 cents. The legacy charges 1689. The cause lives in binary representation: 16.90 doesn’t exist as an exact double (the closest neighbor is 16.89999999999999858 ), and the conversion (int)($total * 100) truncates 1689.9999999999998 down to 1689. The round($total, 2) that closing does doesn’t save it, because the result of round is the same crooked double. One cent per tab, for eight years, on every sum that lands on a neighbor below. The temptation to fix it right now is enormous. Resist it. The characterization test expects 1689 on purpose: PHP // What each table produces today, exceptions normalized as text. // Table 7's 1689 is wrong in the arithmetic and correct in the // characterization.

$cases = [ [4, “table 4: paid 1300 cents”], [7, “table 7: paid 1689 cents”], [9, “table 9: declined (card limit)"], [99, “table 99: tab not found”], ]; $matched = 0; foreach ($cases as [$table, $expected]) { try { $got = controller_pay($table); } catch (DomainException $exception) { $got = “table $table: declined ({$exception->getMessage()})"; } catch (RuntimeException $exception) { $got = “table $table: {$exception->getMessage()}";

} if ($got === $expected) { $matched++; echo “ok $got\n”; } else { echo “FAILED expected [$expected], got [$got]\n”; } } No test framework, on purpose: a table of cases, a loop, and a string comparison are enough, and they run wherever the legacy runs. Notice that the test fences the legacy from the outside, through the public interface (the controller function), without touching the fenced file. And notice what the table freezes: table 4’s correct value, table 7’s wrong value, table 9’s refusal, and table 99’s exception, all carrying the same weight. That’s the contract. From here on, any change that alters one of these four lines is a behavior change, and a behavior change during a migration is a defect, even when the new value is the arithmetically correct one. Table 7’s cent will get fixed, but after the strangling, as a separate change, with the table updated on purpose and Rosie warned. Migration changes structure; a fix changes behavior. Never in the same commit.

Try it: open https://focus.kodel.com.br/en/php/20-01 and run the legacy; table 7 pays 1689 cents. Then open https://focus.kodel.com.br/en/php/20-02 and run the characterization: 4 of 4 cases match, bug included. Now “fix” the truncation in the legacy and run the characterization again. The FAILED that shows up is the test doing its job: you changed behavior in the middle of a migration. Step 2: extract the rule into a pure use case With the fence in place, start moving the business rule to where it should have lived from the start: a pure function, in chapter 14’s shape. Rosie’s payment rule is the limit decision, buried today in the controller as a throw. Extracted, it turns into data going in and a result coming out: Dart · TypeScript PaymentResult payTab(int totalInCents, int limitInCents) { if (totalInCents > limitInCents) { return PaymentDeclined(“card limit”); } return PaymentApproved(totalInCents);

} function payTab( totalInCents: number, limitInCents: number, ): PaymentResult { if (totalInCents > limitInCents) { return { type: “paymentDeclined”, reason: “card limit” }; } return { type: “paymentApproved”, totalInCents }; } Two languages, the same scene: the refusal stopped being an exception and became a Result variant, exactly as chapter 8 called for. The limit stopped being a magic number and became a parameter. And the function tests with two literals, no test double at all, because it depends on nothing. What the use case does not do matters as much as what it does: it doesn’t recompute the tab’s total. The total’s arithmetic, lost cent included, stays the old code’s responsibility, and the next step explains why.

Step 3: wrap the legacy in an adapter Here comes the chapter’s third new concept. A legacy adapter is the old code placed behind the slice’s repository interface: to whoever consumes it, it’s a repository like chapter 15’s; on the inside, the work is done by eight-year-old PHP. One sentence to untangle the name collision: the Adapter from the GoF (Gang of Four, the nickname for the authors of Design Patterns, 1994) catalog converts one interface into another in the general case, and the legacy adapter is that same gesture with one fixed purpose: hide an entire system behind one slice’s contract. It’s the piece that makes migrating without rewriting possible: the new use case sees the legacy as replaceable infrastructure, the same way it would see a database or an API. Dart class LegacyAdapter implements PaymentRepository { @override LookupResult tabTotal(int table) { try { return TotalAvailable(_legacyCents(_legacyCalculateTotal(table))); } on StateError { // The legacy exception becomes a Failure HERE, in one place. return InfraFailure(Failure.tabNotFound);

} } } Two decisions live in this small file. First: the adapter delegates the total’s calculation to the old code, instead of reimplementing the sum. That’s why table 7’s lost cent crosses the adapter intact, and step 1’s characterization keeps passing; if the adapter redid the math “the right way,” table 7 would pay 1690, the test would break, and you’d have changed behavior by accident. Second: the exception the legacy throws gets translated into Failure exactly once, at this boundary, the same rule from chapters 8 and 15. The rest of the new slice never sees a throw from the old world. Whenever the legacy is finally switched off, this is the only file that dies with it. In Go, the same step wears a different face, and the difference is worth learning from. There’s no exception to translate: the legacy’s error is already born a value, in the (T, error) pair. The Go adapter normalizes instead of translating: it takes the old code’s open error and fits it into the slice’s typed Failure . Go func (LegacyAdapter) TabTotal(table int) LookupResult { total, err := legacyCalculateTotal(table) if err != nil {

// The legacy error becomes a Failure HERE, in one place. failure := FailureTabNotFound return LookupResult{Failure: &failure} } return LookupResult{TotalInCents: legacyCents(total)} } The boundary stays a single place; what changes is the verb. In exception-based languages, the boundary translates; in error- as-value languages, it normalizes. If your legacy is Go or Rust, step 3 gets cheaper, and it’s no less necessary for that: a raw error saying “sql: no rows” leaking into the use case couples the new slice to the old database the same way an exception would leak. Step 4: wire the new view to the orchestrator The last step has no new concept, and that’s on purpose: dumb view and orchestrator are chapters 12 and 13, and they work here with zero adaptation. The orchestrator receives the PayTab event, asks the repository (which is the adapter, though it doesn’t know that) for the total, hands the total to the use case, and translates the result into state for the view:

Dart class PaymentOrchestrator { PaymentOrchestrator(this._repository); final PaymentRepository _repository; TabState on(PayTab event) => switch (_repository.tabTotal(event.table)) { TotalAvailable(:final totalInCents) => switch ( payTab(totalInCents, 5000)) { PaymentApproved(:final totalInCents) => Ready(“table ${event.table}: paid $totalInCents cents”), PaymentDeclined(:final reason) => ErrorState(“table ${event.table}: declined ($reason)"), }, InfraFailure() => ErrorState(“table ${event.table}: tab not found”),

}; } The slice is complete, and its drawing shows where the old world ended up:

The proof is still missing. The strangling’s success criterion is objective: the same characterization from step 1, run against the new slice, has to produce the same output, byte for byte. Running the table against the legacy PHP and against the new orchestrator: ok table 4: paid 1300 cents ok table 7: paid 1689 cents ok table 9: declined (card limit) ok table 99: tab not found characterization: 4 of 4 cases match The two outputs are identical; the diff between them is empty. Table 7 keeps paying the wrong 1689 cents, and that’s how you know the migration didn’t change behavior: even the bug arrived alive on the other side. New structure, old behavior, contract fulfilled. Try it: open https://focus.kodel.com.br/en/dart/20-03 (or swap dart for ts , go , kotlin , swift , csharp , python , java , php , rust ) and run the migrated slice: the output is identical to the legacy’s characterization, lost cent included. Then change the limit from 5000 to 6000 in the use case and run it again. Table 9 gets approved, and the characterization’s FAILED shows the test catching a rule change, now in a place where the rule has an owner. When NOT to migrate

This section exists because the whole chapter is a hammer, and after learning the four steps every system starts looking like a nail. It isn’t. I’ve seen more value destroyed by unnecessary migration than by poorly kept legacy, and I stand by the choosing section’s criterion to the end: migrating code that doesn’t change is paying interest on a debt nobody is collecting. The first case is stable code. Rosie’s menu module had 2 commits this quarter, both price adjustments. Is it ugly? Yes. Does it cost anything? No. The right answer for it is a thin characterization (step 1 alone, none of the other three) and nothing else: the fence guarantees nobody breaks it by accident, and the cost of carrying it ugly is zero as long as it doesn’t change. The second case is the system with a marked end of life: if the current card machine gets discontinued by the vendor in eighteen months and the module dies with it, every hour spent migrating is an hour thrown in a bin with a date stamped on it. The third is the module about to be replaced by a purchase: if the coffee shop’s accounting is about to become an off-the-shelf SaaS (Software as a Service) next year, characterize the data export and stop there. The fourth case is the full rewrite, and it arrives disguised as virtue, with four different names in the mouth of whoever proposes it: modernization, standardization, deep refactor, version 2. Under all four it’s always the same sentence: “since the legacy is bad, let’s rewrite everything at once.” Joel Spolsky called the full rewrite “the single worst strategic mistake that any software company can make” in “Things You Should Never Do, Part I” (joelonsoftware.com, 2000), written about Netscape 6, the rewrite that took three years, shipped nothing in between, and handed the market to the competitor. His argument aged well: old, ugly code carries decades of fixes nobody documented, and a rewrite throws those fixes out along with the ugliness. Strangling exists precisely to capture that knowledge (characterization freezes the fixes, including the ones that look

like bugs) instead of betting it on a rewrite. If someone at your company proposes the full rewrite, the counterproposal fits in one sentence: same budget, one slice at a time, value delivered every week. Both worlds on the same counter After the first slice, the coffee shop lives a coexistence that bothers tidy people: payment runs on the new shape, everything else runs on the old PHP, and both worlds share the same counter. The feature board gains a column and turns into the migration’s progress panel: Feature Commits this quarter Strangled? payment 31 yes tab 12 in progress loyalty 9 no inventory 6 no menu 2 no (and maybe never) This table costs five minutes a week and answers the question every boss asks (“how much is left?”) with data instead of a feeling. The real cost of the coexistence is temporary inconsistency: for a few months, a declined payment is a typed PaymentDeclined in the new slice and a DomainException in the rest of the system. Own that cost out loud, with a deadline: inconsistency is an acceptable intermediate state when it has an end date, and an unacceptable final state when it doesn’t. The entire boundary

between the two worlds lives in one file, the adapter, and that’s what keeps the cost low: nobody needs to remember where the old touches the new, because the spot has a name and an address. There’s still the usual criticism, the same one the vertical slice has heard since chapter 11: “now the limit rule exists twice, in the new use case and in the old controller.” It does, and the answer is the defense Jimmy Bogard makes of Vertical Slice Architecture (jimmybogard.com, 2018): coupling slices to eliminate duplication trades a visible, cheap cost for an invisible, expensive one. Here the trade is even worse, because the “reuse” would couple the new code to the old code you’re trying to retire; the duplication during strangling is scaffolding, not debt, and it dismantles itself the moment the last call to the old controller dies. Sandi Metz gave this instinct a ruler in “The Wrong Abstraction” (sandimetz.com, 2016): duplication is cheaper than the wrong abstraction, and a wrong abstraction over a dying legacy is the wrongest of all. Pitfalls The migration that turns into a rewrite. You’re at step 2, in the middle of extracting the limit rule, and you notice the loyalty calculation is a disgrace too. “While we’re at it…” is the sentence that turns a one-week migration into a three-month swamp; every “while we’re at it” doubles the diff and the risk. The slice’s scope is the boundary: loyalty has 9 commits on the board and will get its turn. Write it down, close the current slice’s pull request, migrate the next one when its time comes. The characterization that fixes the bug. You write table 7’s test, see 1689, “know” the right answer is 1690, and write 1690 as the expected value. The test is born red, you “fix” the legacy so it passes, and there it goes: you destroyed the contract the test

existed to freeze. Now there’s no way to tell whether the migrated slice behaves like the legacy, because the legacy changed in the middle of the measurement. Worse: Rosie’s accounting has been closing the register with 1689 for eight years, and your “corrected” cent just created an accounting discrepancy nobody asked for. The characterization test documents what IS. The fix comes later, separate, announced. Q&A The payment slice needs the tab’s data, and the tab is still legacy. Do I migrate both together? No; the adapter is the answer. The new slice sees the tab through the repository interface, and whoever implements that interface today is the old code wrapped up. When the tab slice gets migrated, you swap the implementation behind the interface and the payment use case doesn’t even recompile differently. My system is greenfield; do I throw this chapter away? Keep at least two pieces. The vaccine: when your system turns five and someone proposes a rewrite, you’ll have Spolsky’s argument and a concrete alternative. And the criterion: frequency times pain decides where to invest refactoring in any code, new or old. Shouldn’t the characterization use a real test framework? Inside the new slice, yes, and chapter 17 already did that. To fence the legacy from the outside, the table-plus-loop has an advantage no framework can match: it runs in the legacy’s own environment, no matter how hostile. If your eight-year-old PHP runs on a server that won’t accept Composer, the characterization test runs there just the same.

Quick tip: before writing the first characterization, run git log --since="3 months ago” --name-only and count commits per directory. Ten minutes of shell and you have the frequency column for your own system’s slice board, with real numbers for the meeting where someone is about to propose the full rewrite. Quick reference Step What it does 0. Choose the slice commits this quarter times pain

  1. Characterize freezes what the legacy DOES, bugs included
  2. Use cases rule becomes a pure function: data in, Result out
  3. Adapter legacy behind the interface; exception becomes Failure
  4. View + orchestrator event in, state out Don’t migrate stable, end of life, purchase, full rewrite Step Done criterion
  5. Choose the slice a number defends the choice
  6. Characterize characterization is green against the

legacy 2. Use cases rule tests with literals, no test double 3. Adapter new slice never sees a throw from the legacy 4. View + orchestrator characterization green, identical output Don’t migrate decision written down with the reason Exercises

  1. This chapter’s characterization fenced the tab’s closing. Write the cases that fence the legacy’s other public function, calculate_total , straight against the floats it returns. Watch table 7’s case: your test’s expected value is the double the function returns today, not the $16.90 from bakery arithmetic. If your new case exposes one more lost cent on another tab, even better: freeze that one too.
  2. Strangle the inventory slice on your own, with the four steps. Before you start, reread the choosing section’s board: inventory has 6 commits this quarter and the pain is a manual count at closing time. Finish the exercise by writing down whether this migration should happen at all, and which of the four “when NOT to migrate” cases it touches. Doing the exercise and concluding it shouldn’t have been done is the right answer; knowing how to run the migration and knowing how to refuse it are the same muscle.

Tip 20: migrate what changes, fence what doesn’t. Characterization is the fence; strangling is the change; the frequency-times-pain board says which of the two each piece deserves. Next chapter: the tab slice is next in the migration queue, and you already know its shape by heart. What if the one writing the next slice isn’t you, but a language model?

Powered by TurnKey Linux.