8 Tips To Write Clean Code

8 Tips To Write Clean Code

9 min read ·

Thank you to our sponsors who keep this newsletter free to the reader:

Today's issue is sponsored by Pluralsight. Pluralsight's 2023 State of Cloud report comes with some surprising findings. Did you know 70% of organizations have more than half of their infrastructure in the cloud? Or that 44% of organizations adopt the latest cloud products as soon as they're available? Find out where you stand in the industry - and how to get ahead.

And by IcePanel. IcePanel realigns your engineering & product teammates on the technical decisions made across the business. Explain your complex software systems with an interactive map that teammates can drill down to the level of detail they need.

Clean code is code that's easy to read, maintain, and understand.

I consider writing clean code a skill.

And it's a skill that you can learn and improve with deliberate practice.

My favorite approach for practicing clean coding is doing refactoring exercises.

So I prepared one for you today, and we're going to improve it one step at a time by applying clean code principles.

Let's dive in!

Starting Point

I like starting with a problem when trying to learn new concepts.

And the more illustrative the problem, the better.

So we'll use some poorly written code as the starting point for our refactoring.

And in each step, I will highlight what the current issue is and how we will fix it.

Here's what I see when I look at the Process method:

  • Deep nesting of code - 4 levels, to be precise
  • Precondition checks are applied one after the other
  • Throwing exceptions to represent a failure

How can we turn this into clean code?

public void Process(Order? order)
{
    if (order != null)
    {
        if (order.IsVerified)
        {
            if (order.Items.Count > 0)
            {
                if (order.Items.Count > 15)
                {
                    throw new Exception(
                        "The order " + order.Id + " has too many items");
                }

                if (order.Status != "ReadyToProcess")
                {
                    throw new Exception(
                        "The order " + order.Id + " isn't ready to process");
                }

                order.IsProcessed = true;
            }
        }
    }
}

#1: Early Return Principle

It should be painfully obvious by now that the initial version is deeply nested because of the if statements applying precondition checks.

We'll solve this using the early return principle, which states that we should return from a method as soon as the conditions for that have been met.

In the case of the Process method, this means moving from a deeply nested structure to a set of guard clauses.

public void Process(Order? order)
{
    if (order is null)
    {
        return;
    }

    if (!order.IsVerified)
    {
        return;
    }

    if (order.Items.Count == 0)
    {
        return;
    }

    if (order.Items.Count > 15)
    {
        throw new Exception(
            "The order " + order.Id + " has too many items");
    }

    if (order.Status != "ReadyToProcess")
    {
        throw new Exception(
            "The order " + order.Id + " isn't ready to process");
    }

    order.IsProcessed = true;
}

#2: Merge If Statements To Improve Readability

The early return principle makes the Process method more readable.

But there's no need to have one guard clause after another.

So we can merge all of them into one if statement.

The behavior of the Process method remains unchanged, but we remove a lot of excess code.

public void Process(Order? order)
{
    if (order is null ||
        !order.IsVerified ||
        order.Items.Count == 0)
    {
        return;
    }

    if (order.Items.Count > 15)
    {
        throw new Exception(
            "The order " + order.Id + " has too many items");
    }

    if (order.Status != "ReadyToProcess")
    {
        throw new Exception(
            "The order " + order.Id + " isn't ready to process");
    }

    order.IsProcessed = true;
}

#3: Use LINQ For More Concise Code

A quick improvement can be using LINQ to make the code more concise and expressive.

Instead of checking for Items.Count == 0, I prefer using the LINQ Any method.

You could argue that LINQ has worse performance, but I always optimize for readability.

There are far more expensive operations in an application than a simple method call.

public void Process(Order? order)
{
    if (order is null ||
        !order.IsVerified ||
        !order.Items.Any())
    {
        return;
    }

    if (order.Items.Count > 15)
    {
        throw new Exception(
            "The order " + order.Id + " has too many items");
    }

    if (order.Status != "ReadyToProcess")
    {
        throw new Exception(
            "The order " + order.Id + " isn't ready to process");
    }

    order.IsProcessed = true;
}

#4: Replace Boolean Expression With Descriptive Method

Merging multiple conditions into one if statement means writing less code, but it can decrease readability with complex conditions.

However, you can fix this and improve readability by using a variable or method with a descriptive name.

I prefer using methods, so I will introduce the IsProcessable method to represent the precondition check.

public void Process(Order? order)
{
    if (!IsProcessable(order))
    {
        return;
    }

    if (order.Items.Count > 15)
    {
        throw new Exception(
            "The order " + order.Id + " has too many items");
    }

    if (order.Status != "ReadyToProcess")
    {
        throw new Exception(
            "The order " + order.Id + " isn't ready to process");
    }

    order.IsProcessed = true;
}

static bool IsProcessable(Order? order)
{
    return order is not null &&
           order.IsVerified &&
           order.Items.Any();
}

#5: Prefer Throwing Custom Exceptions

Now let's talk about throwing exceptions. I like to use exceptions for "exceptional" situations only, and I don't use them for flow control in my code.

Having said that, if you do want to use exceptions for flow control, it's better to use custom exceptions.

You can introduce valuable contextual information and better describe the reason for throwing the exception.

And if you want to handle these exceptions globally, you can create a base class to be able to catch specific exceptions.

public void Process(Order? order)
{
    if (!IsProcessable(order))
    {
        return;
    }

    if (order.Items.Count > 15)
    {
        throw new TooManyLineItemsException(order.Id);
    }

    if (order.Status != "ReadyToProcess")
    {
        throw new NotReadyForProcessingException(order.Id);
    }

    order.IsProcessed = true;
}

static bool IsProcessable(Order? order)
{
    return order is not null &&
           order.IsVerified &&
           order.Items.Any();
}

#6: Fix Magic Numbers With Constants

A common code smell I see is the use of magic numbers.

They are usually easy to spot because they're used to check if numeric some condition applies.

The problem with magic numbers is that they carry no meaning.

The code is harder to reason about, and more error-prone.

Fixing magic numbers should be straightforward, and one solution is introducing a constant.

const int MaxNumberOfLineItems = 15;

public void Process(Order? order)
{
    if (!IsProcessable(order))
    {
        return;
    }

    if (order.Items.Count > MaxNumberOfLineItems)
    {
        throw new TooManyLineItemsException(order.Id);
    }

    if (order.Status != "ReadyToProcess")
    {
        throw new NotReadyForProcessingException(order.Id);
    }

    order.IsProcessed = true;
}

static bool IsProcessable(Order? order)
{
    return order is not null &&
           order.IsVerified &&
           order.Items.Any();
}

#7: Fix Magic Strings With Enums

Similar to magic numbers, we have the magic strings code smell.

A typical use case for magic strings is to represent some sort of state.

You'll notice that we're comparing the Order.Status value to a magic string to check if the order is ready to process.

A few problems with magic strings:

  • Easy to make mistakes (typo)
  • Lack of strong typing
  • Not refactoring proof

Let's create an OrderStatus enum to represent the possible states:

enum OrderStatus
{
    Pending = 0,
    ReadyToProcess = 1,
    Processed = 2
}

And now we have to use the appropriate OrderStatus in the check:

const int MaxNumberOfLineItems = 15;

public void Process(Order? order)
{
    if (!IsProcessable(order))
    {
        return;
    }

    if (order.Items.Count > MaxNumberOfLineItems)
    {
        throw new TooManyLineItemsException(order.Id);
    }

    if (order.Status != OrderStatus.ReadyToProcess)
    {
        throw new NotReadyForProcessingException(order.Id);
    }

    order.IsProcessed = true;
    order.Status = OrderStatus.Processed;
}

static bool IsProcessable(Order? order)
{
    return order is not null &&
           order.IsVerified &&
           order.Items.Any();
}

#8: Use The Result Object Pattern

I said I don't prefer using exceptions for flow control. But how can we fix this?

One solution is using the result object pattern.

You can use a generic Result class to represent all types of results or a specific one like ProcessOrderResult.

To make your result objects encapsulated, expose a set of factory methods to create the concrete result type.

public class ProcessOrderResult
{
    private ProcessOrderResult(
        ProcessOrderResultType type,
        long orderId,
        string message)
    {
        Type = type;
        OrderId = orderId;
        Message = message;
    }

    public ProcessOrderResultType Type { get; }

    public long OrderId { get; }

    public string? Message { get; }

    public static ProcessOrderResult NotProcessable() =>
      new(ProcessOrderResultType.NotProcessable, default, "Not processable");

    public static ProcessOrderResult TooManyLineItems(long oderId) =>
      new(ProcessOrderResultType.TooManyLineItems, orderId, "Too many items");

    public static ProcessOrderResult NotReadyForProcessing(long oderId) =>
      new(ProcessOrderResultType.NotReadyForProcessing, oderId, "Not ready");

    public static ProcessOrderResult Success(long oderId) =>
      new(ProcessOrderResultType.Success, oderId, "Success");
}

Using an enum like ProcessOrderResultType will make consuming the result object easier with switch expressions. Here's the enum to represent the ProcessOrderResult.Type:

public enum ProcessOrderResultType
{
    NotProcessable = 0,
    TooManyLineItems = 1,
    NotReadyForProcessing = 2,
    Success = 3
}

And now the Process method becomes:

const int MaxNumberOfLineItems = 15;

public ProcessOrderResult Process(Order? order)
{
    if (!IsProcessable(order))
    {
        return ProcessOrderResult.NotProcessable();
    }

    if (order.Items.Count > MaxNumberOfLineItems)
    {
        return ProcessOrderResult.TooManyLineItems(order);
    }

    if (order.Status != OrderStatus.ReadyToProcess)
    {
        return ProcessOrderResult.NotReadyForProcessing(order);
    }

    order.IsProcessed = true;
    order.Status = OrderStatus.Processed;

    return ProcessOrderResult.Success(order);
}

static bool IsProcessable(Order? order)
{
    return order is not null &&
           order.IsVerified &&
           order.Items.Any();
}

Here's how using an enum for the ProcessOrderResult.Type allows you to write a switch expression:

var result = Process(order);

result.Type switch
{
    ProcessOrderResultType.TooManyLineItems =>
        Console.WriteLine($"Too many line items: {result.OrderId}"),

    ProcessOrderResultType.NotReadyForProcessing =>
        Console.WriteLine($"Not ready for processing {result.OrderId}"),

    ProcessOrderResultType.Success =>
        Console.WriteLine($"Processed successfully {result.OrderId}"),

    _ => Console.WriteLine("Failed to process: {OrderId}", result.OrderId),
};

Takeaway

That's it, 8 tips to write clean code:

Writing clean code is a matter of deliberate practice and experience.

Most people will read about clean coding principles, but few will strive to apply them daily.

This is where you can set yourself apart.

I also made a video about these clean code tips, you can watch it here.

Hope this was helpful.

See you next week!

Today's action step: Take a look at your project and see if you're making some of the mistakes I highlighted here. And then fix them using the clean code tips I shared with you.


Whenever you're ready, there are 4 ways I can help you:

  1. Modular Monolith Architecture (NEW): Join 350+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  2. Pragmatic Clean Architecture: Join 2,700+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Patreon Community: Join a community of 1,050+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.
  4. Promote yourself to 48,000+ subscribers by sponsoring this newsletter.

Become a Better .NET Software Engineer

Join 48,000+ engineers who are improving their skills every Saturday morning.