5 Awesome C# Refactoring Tips to Write Cleaner Code
Learn 5 simple C# refactoring tips with examples in modern C# 14. Make your code cleaner, safer, and easier to read using guard clauses and more.
Imagine your school bag. On Monday morning it is neat. Books on one side, pencils in a pouch, lunch in its box. By Friday it is a mess. Papers are crumpled, the pencils are loose, and you cannot find your eraser. Nothing is broken, but it is hard to use.
Code is the same. When you first write it, it looks fine. After many changes, it gets messy. Refactoring is like cleaning out your bag on Friday evening. You do not buy new books. You do not change your homework. You just put everything back in a tidy place so Monday is easy again.
In this post you will learn 5 simple refactoring tips for C#. Each one makes your code cleaner without changing what it does. We will use modern C# 14 (which ships with .NET 10, the current LTS release) where it helps, but the ideas work in every version.
What refactoring really means
Refactoring has one golden rule: the output must stay the same. You change the shape of the code, not the result.
Because the output must not change, you need a safety net. That safety net is tests. Before you clean up code, write a test that proves it works. After each small change, run the test again. If it still passes, you did not break anything. If it fails, undo your last step and try again.
The safe refactoring loop
Steps
Test
Confirm code works now
Change
Make one small edit
Re-test
Run tests again
Commit
Save the clean step
Now let us look at the 5 tips.
Tip 1: Use guard clauses to flatten deep code
A very common mess is the "arrow" shape. The code keeps going right, deeper and deeper, with if inside if inside if. It looks like the tip of an arrow pointing right. This is hard to read because you have to keep track of many conditions at once.
Here is some arrow-shaped code that checks an order before saving it.
public void PlaceOrder(Order order)
{
if (order != null)
{
if (order.Items.Count > 0)
{
if (order.Customer != null)
{
if (order.Customer.IsActive)
{
Save(order);
}
}
}
}
}A guard clause turns this inside out. Instead of saying "if everything is good, do the work", you say "if something is wrong, leave early". You check the bad cases first and return straight away. The real work then sits at the bottom with no extra indentation.
public void PlaceOrder(Order order)
{
if (order is null)
return;
if (order.Items.Count == 0)
return;
if (order.Customer is null)
return;
if (!order.Customer.IsActive)
return;
Save(order);
}See how flat the second version is? Each guard handles one bad case and gets out of the way. The important line, Save(order), is easy to find. If a guard fails, you can also throw a clear error like throw new ArgumentNullException(nameof(order)) so the caller knows what went wrong.
| Style | Shape | Easy to read? |
|---|---|---|
Nested if | Arrow pointing right | No, you track many conditions |
| Guard clauses | Flat list at the top | Yes, one check per line |
Tip 2: Extract a method and give it a good name
When one method does many jobs, it becomes long and confusing. The fix is Extract Method. You take a block of code, move it into its own small method, and give that method a clear name. The name then explains what the code does, like a label on a box.
Look at this method. It does three different jobs: it works out the price, it adds tax, and it prints a receipt.
public void Checkout(Cart cart)
{
decimal subtotal = 0;
foreach (var item in cart.Items)
subtotal += item.Price * item.Quantity;
decimal tax = subtotal * 0.18m;
decimal total = subtotal + tax;
Console.WriteLine($"Subtotal: {subtotal}");
Console.WriteLine($"Tax: {tax}");
Console.WriteLine($"Total: {total}");
}We can pull each job into its own method. Now Checkout reads almost like plain English.
public void Checkout(Cart cart)
{
decimal subtotal = CalculateSubtotal(cart);
decimal total = AddTax(subtotal);
PrintReceipt(subtotal, total);
}
private decimal CalculateSubtotal(Cart cart) =>
cart.Items.Sum(item => item.Price * item.Quantity);
private decimal AddTax(decimal subtotal) => subtotal + (subtotal * 0.18m);
private void PrintReceipt(decimal subtotal, decimal total)
{
Console.WriteLine($"Subtotal: {subtotal}");
Console.WriteLine($"Total: {total}");
}The rule to remember is simple: one method, one job. A short method with a good name is easier to read, easier to test, and easy to reuse in other places. Visual Studio and Rider can even do this for you. Select the code, press the refactor shortcut, and choose "Extract Method".
Extract Method in three steps
Steps
Select block
Pick one job's code
Name it
Use a clear verb name
Replace
Call the new method
Tip 3: Replace magic numbers and strings with names
A magic number is a value that appears in your code with no explanation. In Tip 2 we used 0.18m for tax. But what is 0.18? A new reader has to guess. And if the tax rate changes, you must hunt for that number everywhere it is hidden.
The fix is to give the value a name using a constant. A const is a value that never changes during the program.
private const decimal TaxRate = 0.18m;
private const int FreeShippingThreshold = 500;
public decimal AddTax(decimal subtotal) => subtotal + (subtotal * TaxRate);
public bool QualifiesForFreeShipping(decimal subtotal) =>
subtotal >= FreeShippingThreshold;Now the code explains itself. TaxRate is clearly the tax. FreeShippingThreshold clearly controls free shipping. If the rate changes from 18% to 20%, you change it in one place and the whole program updates.
This same idea works for magic strings. Instead of typing "Admin" in ten places, make one constant const string AdminRole = "Admin";. One source of truth means fewer typing mistakes.
| Problem | Why it hurts | Fix |
|---|---|---|
Magic number like 0.18 | Nobody knows its meaning | Name it TaxRate |
| Same value in many spots | Easy to miss one when changing | One const, change once |
Magic string "Admin" | A typo becomes a silent bug | Use a named constant |
Tip 4: Turn long if-else chains into a switch expression
When you have many branches that all choose a value based on one input, a long if-else chain becomes tiring to read. C# gives us a cleaner tool: the switch expression. It maps an input straight to a result.
Here is the messy version that picks a discount based on a customer level.
public decimal GetDiscount(string level)
{
if (level == "Gold")
return 0.20m;
else if (level == "Silver")
return 0.10m;
else if (level == "Bronze")
return 0.05m;
else
return 0m;
}The switch expression says the same thing with far less noise. The => means "gives back", and the _ is a catch-all for anything else.
public decimal GetDiscount(string level) => level switch
{
"Gold" => 0.20m,
"Silver" => 0.10m,
"Bronze" => 0.05m,
_ => 0m
};This is shorter, and the compiler can warn you if you forget a case. Modern C# also lets you match on shapes and conditions, which is called pattern matching. For example, you can match on a number range:
public string Grade(int marks) => marks switch
{
>= 90 => "A",
>= 75 => "B",
>= 50 => "C",
_ => "Fail"
};Tip 5: Cut boilerplate with primary constructors
The last tip is about removing repeated "ceremony" code. A very common pattern in C# is a class that takes some services in its constructor and saves them into fields. You end up writing the same name four times: in the parameter, the field, and the assignment.
Here is the old, wordy way.
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly ILogger _logger;
public OrderService(IOrderRepository repository, ILogger logger)
{
_repository = repository;
_logger = logger;
}
public void Save(Order order) => _repository.Add(order);
}C# 12 introduced primary constructors for classes, and they are very handy here. You declare the parameters right next to the class name, and you can use them directly inside the class. No fields, no assignment lines.
public class OrderService(IOrderRepository repository, ILogger logger)
{
public void Save(Order order)
{
logger.LogInformation("Saving order");
repository.Add(order);
}
}The class went from many lines of plumbing to almost none. The behaviour is exactly the same. This is a great example of refactoring: less code to read, same result. C# 14 keeps building on this idea, adding things like the field keyword for cleaner properties and ??= null-conditional assignment, all aimed at reducing this kind of repeated code.
A small word of care: primary constructor parameters are great for short service classes. For larger classes where you also want validation, keeping a normal constructor is still perfectly fine. Refactoring is about choosing the clearest option, not always the shortest.
From boilerplate to primary constructor
Steps
Fields
Declared by hand
Constructor
Assign each field
Primary ctor
One line, use directly
Putting it together
These 5 tips work best as a team. You rarely use just one. A typical cleanup of a messy method might flow like this: add guards at the top, extract the long middle into named methods, name the magic numbers, swap an if-else for a switch, and trim the constructor.
Remember the golden rule the whole time: run your tests after every small step. Tidy in tiny moves, just like cleaning one pocket of your bag at a time. If something breaks, you only have one small change to undo.
One more tip about your tools. You do not have to do all of this by hand. Visual Studio, Rider, and ReSharper all have built-in refactoring commands. Press the lightbulb or the refactor shortcut and they will extract methods, rename safely across the whole project, and remove unused using lines for you. Let the tool do the boring, error-prone parts so you can focus on the thinking.
A note on libraries and licensing
When you refactor, you sometimes reach for a library to do a job for you. Just be aware that licensing changes over time. For example, popular .NET libraries like MediatR and MassTransit have moved to commercial licensing. They are still good tools, but for a school project or a small free app, check the licence first or use the built-in .NET features. Often the cleanest refactoring is to remove a dependency you did not really need.
Quick recap
- Refactoring means cleaning up code without changing what it does. Output stays the same.
- Always keep a test as your safety net, and move in tiny steps.
- Tip 1 - Guard clauses: check bad cases first and
returnearly to keep code flat. - Tip 2 - Extract method: give each job its own small, well-named method. One method, one job.
- Tip 3 - Name your values: replace magic numbers and strings with clear
constnames. - Tip 4 - Switch expression: turn long if-else chains into a short, clear map.
- Tip 5 - Primary constructors: cut repeated constructor plumbing in modern C#.
- Let your editor (Visual Studio, Rider, ReSharper) do the heavy lifting with built-in refactor commands.
- Check library licences (like MediatR and MassTransit) before adding a dependency.
References and further reading
- What's new in C# 14 - Microsoft Learn
- .NET Coding Conventions - Microsoft Learn
- Refactor your C# code with primary constructors - .NET Blog
- .NET code refactoring options - Visual Studio docs
- Replace Nested Conditional with Guard Clauses - Refactoring.Guru
- Extract Method - Refactoring.Guru
- 5 Awesome C# Refactoring Tips - Milan Jovanovic
Related Posts
How to Write Elegant Code with C# Pattern Matching
Learn C# pattern matching the easy way. Use is, switch expressions, property and list patterns to write clean, readable, and elegant .NET code.
Mastering Exception Handling in C#: A Comprehensive Guide
Learn C# exception handling the friendly way: try, catch, finally, custom exceptions, filters, throw vs throw ex, and real best practices for .NET 10.
Why I Switched to Primary Constructors for DI in C#
A friendly guide on why primary constructors made my C# dependency injection cleaner, with simple examples, diagrams, tables, and honest trade-offs.
Why I Write Tall LINQ Queries: Readable C# Pipelines
Learn why writing tall, one-operator-per-line LINQ queries in C# makes your code easier to read, debug, and review. Beginner friendly with diagrams.
From Anemic Models to Behavior-Driven Models: A Practical DDD Refactor in C#
Learn to refactor anemic C# classes into rich, behavior-driven domain models using DDD. A simple, step-by-step guide with diagrams and real code.
How to Replace Exceptions with the Result Pattern in .NET
Learn how to replace exceptions with the Result pattern in .NET for clearer, faster, and safer error handling. Simple guide with C# examples.