Introduction: Why Refactoring Legacy C# Code is a Necessity
Every .NET developer sooner or later encounters legacy code. This is not necessarily a 20-year-old project on .NET Framework 1.1 — often code written a year ago in a hurry, without tests and violating SOLID principles, becomes "legacy." Working with such code turns into a nightmare: any change breaks functionality in unexpected places, and understanding the logic is only possible through debugging.
Refactoring is not rewriting from scratch. It is improving the internal structure of the code without changing its external behavior. In this article, we will analyze practical techniques for refactoring legacy C# code that can be applied immediately, minimizing risks.
1. Preparation: The Golden Rule of Refactoring
Before changing code, make sure you have a "safety net" — a set of automated tests. Without them, refactoring turns into guesswork.
Creating Characterization Tests
If there are no tests, write characterization tests (also known as golden master or snapshot tests). The idea is simple: run the method with different input data, record the result, and then verify that the result has not changed after refactoring.
// Example of a characterization test for a legacy method[Test]public void CalculateTotal_ShouldReturnSameResult_AfterRefactoring(){ // Arrange var order = new Order { Items = new List<Item> { new Item { Price = 100, Quantity = 2 } } }; var expected = 200; // Pre-known result
// Act var result = LegacyOrderCalculator.CalculateTotal(order);
// Assert Assert.AreEqual(expected, result);}Tip: Use libraries like Verify or ApprovalTests for automatic snapshot testing.
2. Technique #1: Extract Method
The simplest and most effective technique. When you see a method 200 lines long with comments like "// discount calculation here" — it is an ideal candidate.
Example: Large Method
public decimal ProcessOrder(Order order){ decimal total = 0; foreach (var item in order.Items) { total += item.Price * item.Quantity; }
// Discount calculation if (order.Customer.IsPremium) { total *= 0.9m; // 10% discount } else if (order.TotalAmount > 1000) { total *= 0.95m; // 5% discount }
// Applying tax total *= 1.2m; // 20% tax
return total;}After Refactoring
public decimal ProcessOrder(Order order){ decimal subtotal = CalculateSubtotal(order.Items); decimal afterDiscount = ApplyDiscount(subtotal, order.Customer); return ApplyTax(afterDiscount);}
private decimal CalculateSubtotal(IEnumerable<Item> items){ return items.Sum(item => item.Price * item.Quantity);}
private decimal ApplyDiscount(decimal total, Customer customer){ if (customer.IsPremium) return total * 0.9m; if (total > 1000) return total * 0.95m; return total;}
private decimal ApplyTax(decimal amount){ return amount * 1.2m;}Benefits:
- Each method has a single responsibility
- The code becomes readable and testable
- The discount logic can be reused in other places
3. Technique #2: Replace Conditional Logic with Polymorphism
Legacy code often contains long chains of if-else or switch statements. This violates the Open/Closed Principle (OCP).
Example: Switch by Document Type
public decimal CalculateShippingCost(Order order){ switch (order.ShippingType) { case "Standard": return 5; case "Express": return 15; case "Overnight