Three Must-Know Refactoring Techniques

Three Must-Know Refactoring Techniques

And the Boy Scout Rule

Code gets messy for various reasons: new features under tight deadlines, patch fixes, or unclear ownership. The good news is, with disciplined refactoring, you can systematically improve it. Let’s explore three must-know techniques—each with a before/after scenario—plus one overarching rule to keep your code in shape.

1. Apply Command-Query Separation (CQS)

Principle

Commands: Mutate state but don’t return data (other than success/failure status).

Queries: Return data but don’t change state.

Why It Helps

• Makes the code’s intent crystal clear—no hidden side effects in queries.

• Simplifies testing: if you’re just reading data, you don’t need to worry about the system’s state changing.

Before

public class UserRepository
{
    private readonly Dictionary<int, string> _users = new();

    // This method both retrieves user info (query) and modifies state (command).
    public string GetOrCreateUser(int userId, string defaultName)
    {
        if (_users.ContainsKey(userId))
        {
            return _users[userId]; // Query: returns user name
        }
        else
        {
            _users[userId] = defaultName; // Command: modifies state
            return defaultName;
        }
    }
}

What’s wrong

We have a single method that returns data (a query) but also modifies the underlying collection (a command). This mixes concerns, makes code harder to test, and can lead to unintended side effects.

After

public record struct OperationResult(bool IsSuccess, string? Message = null);

public class UserRepository
{
    private readonly Dictionary<int, string> _users = new();

    // Command: modifies state, returns a result if needed
    public OperationResult CreateUser(int userId, string defaultName)
    {
        if (_users.ContainsKey(userId))
        {
            return new(false, "User already exists.");
        }

        _users[userId] = defaultName;
        return new(true, "User created successfully.");
    }

    // Query: returns data, no side effects
    public string? GetUserName(int userId) =>
        _users.TryGetValue(userId, out var userName) ? userName : null;
}

What improved

We’ve split the code into:

CreateUser as a pure command (it returns a success/failure result).

GetUserName as a pure query that only reads data.

2. Make Inputs and Outputs Explicit

Principle

• A function’s signature should clearly show what it needs and what it produces.

• If something is meaningful to return, don’t use void; consider returning a specialized result or status object.

Why It Helps

• Promotes clear, self-documenting code—callers see exactly what they can expect.

• Enhances error handling by making outcomes explicit.

Before

public static class FileUtils
{
    // This method's signature doesn't tell us if it succeeded or failed.
    public static void SaveData(string filePath, string data)
    {
        // If something goes wrong, it might throw an exception,
        // but the caller won’t know unless they handle it externally.
        File.WriteAllText(filePath, data);
    }
}

What’s wrong

A void return type hides the outcome. If the caller wants to know whether this operation succeeded or failed (or how many bytes were written), they have to rely on exceptions or external checks.

After

public record struct FileWriteResult(bool Success, int BytesWritten, string? ErrorMessage);

public static class FileUtils
{
    // Now the signature clearly states what’s returned.
    public static FileWriteResult SaveData(string filePath, string data)
    {
        try
        {
            File.WriteAllText(filePath, data);
            return new FileWriteResult(true, data.Length, null);
        }
        catch (IOException ex)
        {
            return new FileWriteResult(false, 0, ex.Message);
        }
    }
}

What improved

We now have a FileWriteResult that explicitly communicates success/failure, bytes written, and any error message. The caller can use this info right away:

var result = FileUtils.SaveData("data.txt", "Hello out there!");

if (!result.Success)
{
    Console.WriteLine($"Error: {result.ErrorMessage}");
}
else
{
    Console.WriteLine($"Wrote {result.BytesWritten} bytes successfully.");
}

3. Use Single Responsibility (Break Down Large Functions)

Principle

• Each method or class should handle only one responsibility.

• Large “do-everything” functions are harder to debug and maintain.

Why It Helps

• Smaller, specialized functions are more readable and straightforward to test.

• Changes to one part of the system are less likely to break another.

Before

public class OrderService
{
    // This method does everything: validation, discount logic, final total calculation, notification...
    public void ProcessOrder(Order order)
    {
        // Validate
        if (order.Items == null || order.Items.Count == 0)
        {
            Console.WriteLine("Invalid order!");
            return;
        }

        // Apply discount
        if (order.Total > 100)
        {
            order.DiscountPercent = 10;
        }

        // Calculate final total
        order.FinalTotal = order.Total - (order.Total * order.DiscountPercent / 100);

        // Notify
        Console.WriteLine($"Order processed. Final total is {order.FinalTotal:C}.");
    }
}

public record class Order(List<string> Items, decimal Total)
{
    public decimal DiscountPercent { get; set; }
    public decimal FinalTotal { get; set; }
}

What’s wrong

The single method, ProcessOrder, handles multiple steps (validation, discount calculation, final total calculation, notification). It’s harder to isolate bugs, modify specific logic, or write unit tests for individual steps.

After

public class OrderServiceRefactored
{
    public bool Validate(Order order)
    {
        return order.Items is { Count: > 0 };
    }

    public void ApplyDiscount(Order order)
    {
        if (order.Total > 100)
        {
            order.DiscountPercent = 10;
        }
    }

    public void CalculateFinalTotal(Order order)
    {
        order.FinalTotal = order.Total - (order.Total * order.DiscountPercent / 100);
    }

    public void Notify(Order order)
    {
        Console.WriteLine($"Order processed. Final total is {order.FinalTotal:C}.");
    }

    public void ProcessOrder(Order order)
    {
        if (!Validate(order))
        {
            Console.WriteLine("Invalid order!");
            return;
        }

        ApplyDiscount(order);
        CalculateFinalTotal(order);
        Notify(order);
    }
}

What improved

Each step is now in its own function, making it simple to test, maintain, or change each concern independently. ProcessOrder orchestrates them but keeps the logic neatly separated.

One Rule to Live By: The Boy Scout Rule

The Boy Scout Rule: Leave the code better than you found it.

Whenever you touch a piece of code—fix a bug, add a feature, etc.—improve its quality, even if it’s just a little bit. Over time, these small improvements add up, transforming messy sections into clean, reliable code.

Conclusion

Refactoring is an ongoing process, amigo. By following these three core techniques—Command-Query Separation, explicit function signatures, and single-responsibility functions—plus adopting the Boy Scout Rule, you’ll steadily improve any codebase you work on. Remember, small, consistent steps lead to big improvements in the long run.