- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Refactor a Fragile Legacy C# Codebase
InvoiceGlobomantics has calculated every invoice for a wholesale retailer for ten years. Its core is a single LegacyInvoiceProcessor class whose 200-line Calculate method mixes discount math, tax rounding, formatting, logging, and email notification, written in a decade-old C# style with ArrayLists and copy-pasted formatting logic. The business now wants seasonal promotions and a loyalty program, but nobody trusts the class enough to edit it directly. You will work through the same process a senior engineer would: measure which files are actually risky, make the legacy code safe to touch, carve out extensibility points, invert its dependencies, and finally migrate traffic to the rebuilt implementation behind a router that can fall back to the original at any time.
Lab Info
Table of Contents
-
Challenge
Step 1: Meet InvoiceForge and its risk
InvoiceGlobomantics: ten years of trusted, feared code
InvoiceGlobomantics has priced and billed every order for a wholesale retailer since before most of your team joined. Its core is a single class,
LegacyInvoiceProcessor, whoseCalculatemethod runs to roughly 200 lines: discount math, tax rounding, string formatting, logging, and an email call, all in one block, written in a decade-old C# dialect withArrayLists and copy-pasted formatting. It works. Nobody on the team will volunteer to change it.The business now wants two things this class was never built for: seasonal promotions and a loyalty program. Both mean touching code that everyone is afraid of, because nobody can say with confidence what else it might break. ## Why this is scary, specifically
Open
LegacyInvoiceProcessorand you'll find the usual signs of a god class: one method doing five jobs, string comparisons standing in for lookup tables, a rounding rule that quietly differs by code path, and formatting logic duplicated between the customer-facing summary and the email body. There is no test file for it. Every fix anyone has made in the last few years has been a small, local patch, layered on top of the last one, because a bigger change felt too risky to attempt without a safety net.That instinct is correct. The fix is not to be braver about editing it — it's to build the safety net first, then make the changes small enough that each one is easy to verify. ## The plan for this lab
You'll work through the same sequence a senior engineer would bring to a codebase like this:
- Measure risk — turn commit history and coverage data into a ranked list of dangerous files, so you know exactly what you're dealing with before you touch anything.
- Modernize and decompose safely — pin down what the god method does today with a characterization test, break it into small pieces behind that test, then replace outdated C# constructs, fixing the bugs the tangled code was hiding along the way.
- Separate concerns and invert dependencies — pull cross-cutting behavior into a pipeline and a decorator, prove an injected dependency can be swapped for a fake, and split a bloated interface so each consumer depends only on what it actually uses.
- Migrate traffic with Strangler Fig routing — stand up a router that defaults to the legacy path, cuts specific segments over to the rebuilt implementation, shadow-compares the two so any drift is caught immediately, and backs the cut-over with an integration test that runs both engines for real.
Each step leaves behind working, checkable code. By the end, InvoiceGlobomantics's riskiest file will no longer be the one everyone avoids. ## See it run before you change anything
Before you edit a line, run the program as it stands. In the Terminal tab, run:
dotnet run --project src/Lab.csprojThe first run builds the project, so give it a moment. The output walks through every part of InvoiceGlobomantics you will touch in this lab, in step order.
Right now only one line shows real work: under Step 3, the legacy
Calculatemethod prices a three-line New York invoice at 366.90, because that code already exists and has been billing customers for a decade. Everything else prints zero,(empty), or a message saying nothing was produced yet -- those are the pieces you will build.Run the same command again after any task to watch the output fill in.
-
Challenge
Step 2: Measure legacy risk before touching the code
Step 2: Measure legacy risk before touching the code
Before any refactor, you need evidence for what everyone already suspects:
LegacyInvoiceProcessoris the most dangerous file in the repository. Two signals, combined, make that case. Churn (how often a file changes) comes from commit history. A file touched in every other commit has had many hands in it and many chances to drift from its original intent. Coverage gap (how much of a file’s behavior no test exercises) tells you how much of that churn happened without a safety net to catch regressions.Neither signal alone is convincing. A file can churn constantly because it’s actively and safely maintained with full test coverage. A file can have low coverage but rarely change, which makes it lower urgency. It’s the combination of heavy edits and poor testing that marks a file as the one to refactor first. With churn scores in hand, the next step folds in coverage. A file's risk should rise with churn but fall as its coverage improves — a heavily-touched file that is also thoroughly tested is far less worrying than one touched just as often with no tests at all. Expressing that as
churn * (1 - coverage)gives a single number you can sort by, and turns "we're all scared of this file" into a ranked, defensible list. -
Challenge
Step 3: Modernize syntax and break down the god method
Step 3: Modernize syntax and break down the god method
With the risk report confirming
LegacyInvoiceProcessor.csat the top, it’s time to open it.Calculatedoes everything inline: it loops over anArrayListof lines, works out each line’s tier discount with a nestedif/else, then picks a tax rate with aswitchstatement and rounds the result. The helper methods below it (CalculateLineDiscount,CalculateTax, andBuildInvoiceSummary) are empty shells someone started years ago and never finished.Nothing tests any of it, so the first move is not to edit
Calculateat all. It is to write down whatCalculatedoes today, so every change after this one can be checked against it. ## Capture legacy behavior with a characterization testA characterization test (also called a golden-master test) doesn't assert what the code should do. It records what the code actually does, bugs included, and fails if that ever changes. You don't work the expected value out by hand: you run the code, capture its output, and paste that output into the test. From then on the test is a tripwire. As long as it passes, your refactoring hasn't changed behavior.
The sample invoice mixes Gold, Silver, and untiered lines shipped to New York. It deliberately stays on paths nobody has reported a bug in, so this golden master should never need to change. The known bugs get their own targeted tests later in this step, where changing the behavior is the point. ## Split the god method with Extract Method
With the golden master in place, you can split
Calculatesafely. Extract Method is the most basic refactoring there is: move a block of code that does one job into its own well-named method, and call that method from where the block used to be. Done right, nothing observable changes, which is exactly what your characterization test checks.Move each block across exactly as it is: the same nested
if/else, the sameswitchstatement, the same rounding call. Resist fixing anything on the way. Keeping the extraction separate from the fixes is what lets the golden master tell you, unambiguously, that the extraction itself was safe.Notice that
CalculateLineDiscountandCalculateTaxare declaredvirtual. That is a deliberate test seam: a test can subclassLegacyInvoiceProcessor, override one piece, and confirmCalculatereally delegates to it instead of keeping a hidden inline copy. The check for this task does exactly that. Now each pricing rule lives in its own small method, and the style fixes are mechanical: nestedif/elsechains andswitchstatements with repeatedbreaks become switch expressions, and manualArrayListloops become LINQ. Rewriting the style is also how the bugs surface -- anelsebranch quietly swallowing a tier is easy to skim past in the old syntax and impossible to miss once every case is laid out as a switch expression. And becauseCalculatenow delegates to these methods, every fix you make here reaches every invoice it prices. Tax works the same way as discount, but the bug here is different: instead of a missing case, it's a case that quietly reuses the wrong fallback rate, and a rounding call that doesn't match the rule accounting actually expects for half-cent amounts. Modernizing theswitchinto aswitchexpression is what makes the missing"CA"case obvious enough to add. The last piece is output. The legacy code builds its summary with anArrayListand a hand-rolled loop, and only shows the unit price. Quantity has been silently dropped from what the customer sees. A LINQSelectprojection replaces the loop and is also where you'll add the missing quantity and extended price back in. -
Challenge
Step 4: Separate concerns and invert dependencies
Step 4: Separate concerns and invert dependencies
With the god method broken into correct, focused pieces, the next problem is structural: cross-cutting behavior like logging and rounding is entangled with the calculation itself, seasonal pricing has nowhere to live without editing the calculator directly, and the processor reaches out to concrete classes instead of abstractions it could swap. Four small design moves fix this, each targeting a different file.
First: an ordered pipeline. Instead of one method doing logging, rounding, and anything else in a fixed sequence baked into its body, an
IInvoiceStepinterface lets each concern be its own class, and a pipeline runner threads anInvoiceContextthrough them in order — so adding a new step later means writing one class, not editing existing code. Second: a decorator for seasonal pricing. Rather than adding anif (isSeasonalPromotion)branch inside the base calculator,SeasonalPricingDecoratorwraps anyIInvoiceCalculatorand adjusts its result — the base calculator never has to know a promotion exists, and the decorator itself can wrap another decorator later without either one changing. Third: dependency inversion on notifications.InvoiceNotificationServicealready takes anINotificationSenderthrough its constructor instead of constructing a concrete SMTP client — that's the inversion. What's missing is the method actually using it instead of doing nothing, which is what makes the abstraction swappable in tests and in production alike. Dependency inversion only pays off if the abstraction really is swappable, and the way to prove that is to substitute something that isn't a production implementation at all. A fake implements the same interface but records what it was asked to do instead of doing it. IfInvoiceNotificationServicesecretly relied on anything transport-specific, a fake couldn't stand in for it. Because it depends only onINotificationSender, any implementation will do, including one that lives entirely in your test project and never sends a thing. Fourth: interface segregation. A single bloatedIInvoiceServiceforces every consumer to depend on methods it doesn't call. Splitting it intoIInvoiceCalculatorRoleandIInvoiceFormatterRolelets a consumer depend on only the role it needs.StandardInvoiceServiceimplements both, andSummarizeis where you prove the split still adds up to the same result by composing the two roles explicitly. -
Challenge
Step 5: Migrate traffic with Strangler Fig routing
Step 5: Migrate traffic with Strangler Fig routing
The refactored engine now exists side by side with the legacy one, but rewriting is only half the job — moving real invoice traffic over without an outage or a silent billing error is the other half. The Strangler Fig pattern handles this by routing traffic through a layer that defaults to the old implementation and only diverts specific, chosen slices of traffic to the new one, so a bad surprise in the rebuilt engine affects a bounded segment instead of every customer at once.
InvoiceRouteris that layer. It holds both engines and a set of customer segments that have been cleared to use the modern one; everything else still goes to legacy by default. Routing traffic to the modern engine is only safe if you can tell whether it agrees with the legacy engine on segments that haven't been cut over yet.ShadowComparerruns both engines against the same input without exposing the modern result to the customer, and flags any case where the two disagree — turning a silent regression into a visible one, before it ever reaches invoice traffic. Shadow comparison catches drift in live traffic, but you shouldn't have to wait for live traffic to find out the engines disagree. An integration test wires the real pieces together: a realInvoiceRouter, theLegacyInvoiceEnginethat wrapsLegacyInvoiceProcessor.Calculate, and the rebuiltModernInvoiceEngine(both engines live insrc/Routing/InvoiceEngines.cs). It checks that a migrated segment is billed exactly what it would have been on legacy.Unlike the unit tests so far, it fakes nothing, so it exercises the code you refactored in Step 3 as well. The California and half-cent cases only agree because you fixed the CA rate and the rounding rule in Task 3.4. The last piece is how a segment actually gets promoted.
MigrationPlanowns the set of migrated segments thatInvoiceRouterreads from, and the promotion itself needs to report whether it was new — attempting to migrate a segment twice should be a distinguishable no-op, not a silent success. -
Challenge
Step 6: Run the application
Every task is done, and each one was graded the moment you completed it. What you have not done yet is watch the whole application run at once, which is the only place the pieces you wrote separately become a single working program.
Start the application
Open the Terminal tab and run:
dotnet run --project src/Lab.csprojThe program runs in the terminal and prints its output there. Every behavior it shows is code you wrote in the previous steps, so read the output against what each task asked for. That is the whole lab, working as one program. When you are done, everything you wrote is still in the editor tabs, so you can keep coding in the lab.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.