đź§ą Refactoring in C#: Practical Examples for Clean and Maintainable Code đź§ą
By Gary Butler 15 min read
đźš© Introduction
Refactoring is the art of improving code without changing its external behavior. It helps make code cleaner, more efficient, and easier to maintain. In this blog, I will explore common refactoring patterns with examples in C#.
1. Replace Nested Conditionals with Guard Clauses
Problem
Deeply nested conditionals make code hard to read and maintain.
Solution
Use guard clauses to handle exceptional cases upfront.
Before
public void ProcessOrder(Order? order)
{
if (order != null)
{
if (order.Status == OrderStatus.Pending)
{
// Process the order
}
}
}
After
public void ProcessOrder(Order? order)
{
if (order == null) return;
if (order.Status != OrderStatus.Pending) return;
// Process the order
}
Benefit
Guard clauses simplify the structure by reducing nesting.
2. Extract Method
Problem
A single method is doing too much, making it hard to test or understand.
Solution
Extract logically distinct operations into smaller methods.
Before
public void GenerateReport()
{
Console.WriteLine("Connecting to database...");
// Database connection logic
Console.WriteLine("Fetching data...");
// Data fetching logic
Console.WriteLine("Generating report...");
// Report generation logic
}
After
private void ConnectToDatabase() => Console.WriteLine("Connecting to database...");
private void FetchData() => Console.WriteLine("Fetching data...");
private void Generate() => Console.WriteLine("Generating report...");
public void GenerateReport()
{
ConnectToDatabase();
FetchData();
Generate();
}
Benefit
Each method has a single responsibility, improving readability and testability.
3. Replace Magic Numbers with Constants
Problem
Magic numbers obscure the purpose of the code.
Solution
Replace them with meaningful constants.
Before
public double CalculateArea(double radius) =>
3.14159 _ radius _ radius;
After
private const double Pi = 3.14159;
public double CalculateArea(double radius) =>
Pi _ radius _ radius;
Benefit
Constants make the code self-explanatory and easier to maintain.
4. Introduce Parameter Object / DTO
Problem
A method takes too many parameters, making it hard to understand and at risk of positional errors.
Solution
Group related parameters into a single record when the number of parameters exceeds two.
Before
public void CreateOrder(string customerName, string productName, int quantity, double price)
{
// Order creation logic
}
After
public record OrderDetails
{
public required string CustomerName { get; init; }
public required string ProductName { get; init; }
public required int Quantity { get; init; }
public required double Price { get; init; }
}
public void CreateOrder(OrderDetails orderDetails)
{
// Order creation logic
}
Benefit
Parameter objects reduce method complexity and improve reusability.
5. Replace Loops with LINQ
Problem
Loops can be verbose and less expressive.
Solution
Use LINQ with Lambdas; Method Syntax
; for concise and declarative expressions.
Before
List<string> activeUsers = new List<string>();
foreach (var user in users)
{
if (user.IsActive)
{
activeUsers.Add(user.Name);
}
}
After
var activeUsers = users
.Where(user => user.IsActive)
.Select(user => user.Name)
.ToList();
Benefit
LINQ expressions are more concise and easier to read.
6. Fluent Style with Custom Builders
Problem
Complex object creation often involves multiple steps, leading to verbose, repetitive, and error-prone code.
Solution
Introduce a fluent builder pattern for more readable and maintainable object construction.
Before
var report = new Report();
report.SetTitle("Annual Report");
report.SetAuthor("Jane Doe");
report.AddSection("Introduction", "This is the introduction.");
report.AddSection("Summary", "This is the summary.");
report.Publish();
After
var report = new ReportBuilder()
.WithTitle("Annual Report")
.WithAuthor("Jane Doe")
.AddSection("Introduction", "This is the introduction.")
.AddSection("Summary", "This is the summary.")
.Build();
report.Publish();
Benefit
The fluent interface improves readability, supports chaining, and simplifies object creation while encapsulating complexity in the builder.
7. Using the Adapter Pattern with Fluent Style
Problem
When working with data from external sources, it’s common to encounter mismatches between the expected structure and the available data.
Solution
By using an adapter pattern you can convert one interface or data structure into another, making the integration seamless. Readability can be further enhanced by using a Fluent style for the adaptors.
Before (without Adapter pattern):
public record Category
{
public required string Name { get; init; }
public required string Description { get; init; }
}
public record UploadCategoryModel
{
public required string CategoryName { get; init; }
public required string CategoryDescription { get; init; }
}
public class CategoryProcessor
{
public List<UploadCategoryModel> ConvertCategoriesToUploadModels(List<Category> categories)
{
var uploadCategories = new List<UploadCategoryModel>();
foreach (var category in categories)
{
uploadCategories.Add(
new UploadCategoryModel()
{
CategoryName = category.Name,
CategoryDescription = category.Description
});
}
return uploadCategories;
}
public void ProcessCategories()
{
var categories = View.GetCategories();
var uploadCategories = ConvertCategoriesToUploadModels(categories);
Repository.SaveCategories(uploadCategories);
}
}
After (using Adapter pattern with fluent style)
You can implement an adapter that transforms the Category
to UploadCategoryModel
. This allows us to separate concerns, where the adapter handles the conversion logic. Additionally, we use fluent style to streamline the process.
public static class CategoryExtensions
{
// Adapter to convert a Category to UploadCategoryModel
public static UploadCategoryModel ToUploadCategoryModel(this Category category) =>
new()
{
CategoryName = category.Name,
CategoryDescription = category.Description
};
// Adapter method to convert a list of categories to a list of upload models
public static IEnumerable<UploadCategoryModel> ToUploadCategoryModels(this IEnumerable<Category> categories) =>
categories.Select(ToUploadCategoryModel);
}
public class CategoryProcessor
{
public void ProcessCategories()
{
var uploadCategories = View
.GetCategories()
.ToUploadCategoryModels();
Repository.SaveCategories(uploadCategories);
}
}
Explanation
-
Adapter: The extension method
ToUploadCategoryModel
converts aCategory
record to anUploadCategoryModel
. -
Fluent Style: The
ToUploadCategoryModels
extension method is chained to directly transform a collection ofCategory
objects into a collection ofUploadCategoryModel
objects. Note the use of aMethod Group
in the calling ofToUploadCategoryModel
Benefit
-
Separation of Concerns: The conversion logic is abstracted into the adapter, making the
CategoryProcessor
class cleaner and easier to maintain. -
Fluent Style: The fluent methods make the transformation process more readable and concise.
Refactoring Patterns in .NET 8
.NET 8 brings powerful language enhancements like advanced pattern matching and switch expressions, which streamline complex control flows and improve code readability. This section explores how to refactor C# code using these modern features.
1. Replace Nested Conditionals with Pattern Matching and Switch expressions
Problem
Deeply nested if-else
statements can be verbose and error-prone.
Solution
Use pattern matching and switch expressions
to simplify conditionals.
Before
public string GetDiscountMessage(Customer? customer)
{
if (customer == null)
{
return "Customer not found.";
}
if (customer.IsActive)
{
if (customer.Type == CustomerType.Premium)
{
return "You get a 20% discount!";
}
if (customer.Type == CustomerType.Regular)
{
return "You get a 10% discount.";
}
}
return "No discount available.";
}
After
public string GetDiscountMessage(Customer? customer) =>
customer switch
{
null => "No Customer supplied.",
{ Type: CustomerType.Premium, IsActive: true } => "You get a 20% discount!",
{ Type: CustomerType.Regular, IsActive: true } => "You get a 10% discount.",
_ => "No discount available."
};
Benefit
Pattern matching and switch expressions simplifies code by concisely handling multiple cases in a single expression.
2. Combine Type Checking with Pattern Matching
Problem
Manual type checking is verbose and can lead to boilerplate.
Solution
Use type patterns to directly match and cast.
Before
public void Process(Entity entity)
{
if (entity is Customer customer)
{
Console.WriteLine($"Processing customer: {customer.Name}");
}
else if (entity is Order order)
{
Console.WriteLine($"Processing order: {order.Id}");
}
else
{
Console.WriteLine("Unknown type.");
}
}
After
public void Process(Entity entity) =>
entity switch
{
Customer customer => Console.WriteLine($"Processing customer: {customer.Name}"),
Order order => Console.WriteLine($"Processing order: {order.Id}"),
_ => Console.WriteLine("Unsupported entity.")
};
Benefit
Type patterns eliminate the need for explicit casting, reducing boilerplate.
3. Simplify Complex If Statements with Property Patterns
Problem
If statements
handling nested conditions can be hard to follow.
Solution
Use property patterns to directly evaluate object properties.
Before
public string GetShippingCost(Order order)
{
if (order.Customer.Type == CustomerType.Premium && order.TotalAmount > 100)
{
return "Free Shipping";
}
else if (order.Customer.Type == CustomerType.Regular && order.TotalAmount > 50)
{
return "$5 Shipping";
}
return "$10 Shipping";
}
After
public string GetShippingCost(Order order) =>
order switch
{
{ Customer: { Type: CustomerType.Premium }, TotalAmount: > 100 } => "Free Shipping",
{ Customer: { Type: CustomerType.Regular }, TotalAmount: > 50 } => "$5 Shipping",
_ => "$10 Shipping"
};
Benefit
Property patterns make conditions clearer and more concise.
4. Use List Patterns for Collection Matching
Problem
Iterating over collections to match patterns is verbose.
Solution
Use list patterns to directly match collection shapes.
Before
public string AnalyzeNumbers(int[] numbers)
{
if (numbers.Length == 0)
{
return "Empty array.";
}
if (numbers.Length == 1 && numbers[0] == 42)
{
return "The answer to life.";
}
if (numbers[0] == 1 && numbers[^1] == 10)
{
return "Starts with 1, ends with 10.";
}
return "No match.";
}
After
public string AnalyzeNumbers(int[] numbers) => numbers switch
{
[] => "Empty array.",
[42] => "The answer to life.",
[1, .., 10] => "Starts with 1, ends with 10.",
_ => "No match."
};
Benefit
List patterns simplify working with arrays and collections.
5. Using and
, or
and is
for Cleaner Logic
Problem
Logical operators like &&
and \|\|
can clutter conditions and make logic harder to read, especially in complex expressions.
Solution
Replace &&
and \|\|
with the new and
and or
keywords for improved clarity and integration with modern C# pattern matching.
Before
public void ProcessValue(int value)
{
if (value > 0 && value < 100)
{
Console.WriteLine("Value is within range.");
}
else if (value <= 0 || value >= 100)
{
Console.WriteLine("Value is out of range.");
}
else
{
Console.WriteLine("Unknown value.");
}
}
After
public void ProcessValue(int value)
{
if (value is > 0 and < 100)
{
Console.WriteLine("Value is within range.");
}
else if (value is <= 0 or >= 100)
{
Console.WriteLine("Value is out of range.");
}
else
{
Console.WriteLine("Unknown value.");
}
}
Benefit
and
, or
and is
improve readability by making conditions more intuitive and expressive, particularly when combined with range patterns.
6. Using and
and or
in Switch Logical Patterns
Problem
Traditional logical operators in switch
expressions require verbose when
clauses, reducing clarity.
Solution
Directly embed and
and or
into switch expressions for concise logic.
Before
public string GetValueDescription(int value)
{
return value switch
{
int n when n > 0 && n < 100 => "Value is within range.",
int n when n <= 0 || n >= 100 => "Value is out of range.",
_ => "Unknown value."
};
}
After
public string GetValueDescription(int value) =>
value switch
{
> 0 and < 100 => "Value is within range.",
<= 0 or >= 100 => "Value is out of range.",
_ => "Unknown value."
};
Benefit
Using and
and or
in logical patterns eliminates the need for when clauses, leading to cleaner and more readable expressions.
🎯 Conclusion
Refactoring is an ongoing process that improves code quality and developer productivity. By applying patterns like guard clauses, method extraction, and pattern matching, you can write cleaner, more maintainable C# code.
Having unit tests with a high code coverage will ensure that refactoring can be done while ensuring the stability of the code.
Start small, refactor incrementally, and always prioritize readability and simplicity.
Resources
- Refactoring: Improving the Design of Existing Code by Martin Fowler
- C# Documentation
- Clean Code Principles
- This site has a number of paid courses but I suggest looking at the topics covered as a conversation starter.