Writing Clean and Maintainable Functions in C#

Aditya Sharma - Sep 7 - - Dev Community

Introduction

In the world of software development, the quality of your code is paramount. It’s not just about getting things to work but ensuring that your code is easy to read, understand, and maintain. In this blog post, we’ll dive into the principles of writing clean code, using C# to illustrate key points, bad and good code examples to demonstrate the usage of functions.

Small Functions: Keep It Short and Simple(KISS)

KISS

One of the first principles clean code emphasizes is that functions should be small—ideally just a few lines of code(preferably of 4 lines). This makes your functions easier to understand and reduces the cognitive load on the reader.

Bad code ❌

public void ProcessOrder(Order order)
{
    if (order.IsValid)
    {
        order.Status = "Processing";
        order.ProcessingDate = DateTime.Now;
        LogOrder(order);
        UpdateInventory(order);
        SendConfirmationEmail(order);
    }
    else
    {
        throw new InvalidOperationException("Invalid order");
    }
}
Enter fullscreen mode Exit fullscreen mode

Good code ✔

public void ProcessOrder(Order order)
{
    ValidateOrder(order);
    MarkOrderAsProcessing(order);
    LogOrder(order);
    UpdateInventory(order);
    SendConfirmationEmail(order);
}

private void ValidateOrder(Order order)
{
    if (!order.IsValid)
        throw new InvalidOperationException("Invalid order");
}

private void MarkOrderAsProcessing(Order order)
{
    order.Status = "Processing";
    order.ProcessingDate = DateTime.Now;
}

Enter fullscreen mode Exit fullscreen mode

In the good example, each function does one thing, making the code easier to read and maintain.

Descriptive Names: Let Your Code Speak for Itself

Function names should clearly describe what the function does. This makes the code self-documenting and reduces the need for comments.

Bad code ❌

public void Process(Order o)
{
    // Validate the order
    if (!o.IsValid)
        throw new InvalidOperationException("Invalid order");

    // Change status and date
    o.Status = "Processing";
    o.ProcessingDate = DateTime.Now;

    // Log the order
    LogOrder(o);

    // Update inventory
    UpdateInventory(o);

    // Send confirmation email
    SendConfirmationEmail(o);
}
Enter fullscreen mode Exit fullscreen mode

Good code ✔

public void ProcessOrder(Order order)
{
    ValidateOrder(order);
    MarkOrderAsProcessing(order);
    LogOrder(order);
    UpdateInventory(order);
    SendConfirmationEmail(order);
}
Enter fullscreen mode Exit fullscreen mode

By using descriptive names like ValidateOrder and MarkOrderAsProcessing, the code is easier to understand without needing comments.

Single Responsibility Principle: One Function, One Task

A function should do only one thing and do it well. If you find your function doing multiple tasks, it’s a sign that it needs to be broken down into smaller functions.

Bad code ❌

public void SaveAndLogOrder(Order order)
{
    SaveOrderToDatabase(order);
    LogOrder(order);
}
Enter fullscreen mode Exit fullscreen mode

Good code ✔

public void SaveOrder(Order order)
{
    SaveOrderToDatabase(order);
}

public void LogOrder(Order order)
{
    // Logging logic here
}
Enter fullscreen mode Exit fullscreen mode

Here, saving and logging are separate concerns and should be handled by different functions.

Avoid Side Effects: Make Functions Predictable

Functions should avoid causing side effects—unexpected changes to the state of the program. Side effects can lead to bugs and make your code harder to understand.

Bad code ❌

public void ProcessOrder(Order order)
{
    UpdateInventory(order); // Reduces inventory count
    SendConfirmationEmail(order);
}
Enter fullscreen mode Exit fullscreen mode

Good code ✔

public void ProcessOrder(Order order)
{
    AdjustInventory(order);
    SendConfirmationEmail(order);
}

private void AdjustInventory(Order order)
{
    // Logic to safely adjust inventory here
}
Enter fullscreen mode Exit fullscreen mode

In the good example, the function AdjustInventory clearly indicates that the inventory is being modified, making the code easier to understand and debug.

Command-Query Separation: Do One or the Other

A function should either perform an action (a command) or return data (a query), but not both. Mixing these can lead to confusion and bugs.

Bad code ❌

public bool ProcessOrder(Order order)
{
    if (order.IsValid)
    {
        SaveOrder(order);
        return true;
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

Good code ✔

public void ProcessOrder(Order order)
{
    ValidateOrder(order);
    SaveOrder(order);
}

public bool IsOrderValid(Order order)
{
    return order.IsValid;
}
Enter fullscreen mode Exit fullscreen mode

Here, ProcessOrder is a command, and IsOrderValid is a query. Each function has a clear and distinct responsibility.

Prefer Fewer Arguments: Less Is More

Functions should have as few arguments as possible (One argument per function is recommended). If a function has too many arguments, it’s a sign that it may be doing too much.

Bad code ❌

public void CreateOrder(string customerName, string productName, int quantity, DateTime orderDate)
{
    // Implementation here
}
Enter fullscreen mode Exit fullscreen mode

Good code ✔

public void CreateOrder(OrderDetails orderDetails)
{
    // Implementation here
}

public class OrderDetails
{
    public string CustomerName { get; set; }
    public string ProductName { get; set; }
    public int Quantity { get; set; }
    public DateTime OrderDate { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

By encapsulating related parameters into an object, you reduce the number of arguments and make the function easier to use and understand.

Use Exceptions, Not Error Codes

Exception handling

Instead of returning error codes, functions should throw exceptions to indicate that something went wrong. This makes error handling more straightforward.

Bad code ❌

public int SaveOrder(Order order)
{
    if (order.IsValid)
    {
        // Save logic
        return 0; // Success
    }
    return -1; // Error
}
Enter fullscreen mode Exit fullscreen mode

Good code ✔

public void SaveOrder(Order order)
{
    if (!order.IsValid)
        throw new InvalidOperationException("Invalid order");

    // Save logic here
}
Enter fullscreen mode Exit fullscreen mode

Using exceptions instead of error codes simplifies error handling and makes the code easier to read.

DRY Principle: Don’t Repeat Yourself

Avoid duplicating code by extracting common functionality into separate functions. This makes your code more maintainable and easier to update.

Bad code ❌

public void UpdateInventoryAndNotify(Order order)
{
    // Update inventory
    UpdateInventory(order.ProductId, order.Quantity);

    // Send notification
    NotifyUser(order.CustomerId, "Your order has been processed.");
}

public void UpdateStockAndNotify(Order order)
{
    // Update stock
    UpdateStock(order.ProductId, order.Quantity);

    // Send notification
    NotifyUser(order.CustomerId, "Your order has been processed.");
}
Enter fullscreen mode Exit fullscreen mode

Good code ✔

public void UpdateInventoryAndNotify(Order order)
{
    UpdateInventory(order.ProductId, order.Quantity);
    NotifyCustomer(order.CustomerId, "Your order has been processed.");
}

public void NotifyCustomer(int customerId, string message)
{
    // Notification logic here
}
Enter fullscreen mode Exit fullscreen mode

By extracting the notification logic into its own function, we reduce duplication and make the code easier to maintain.

Conclusion

Writing clean and maintainable functions is a crucial skill for any developer. We can write functions that are easy to read, understand, and maintain. Remember to keep our functions small, give them descriptive names, avoid side effects, and always aim for clarity. Our future self (and your colleagues) will thank you for it!

. . . . . . . . .