Learnixo
Back to blog
Backend Systemsbeginner

Writing Clean Code with C# — Readability & Simplicity

Practical clean code techniques for C# developers: meaningful naming, small focused methods, avoiding magic numbers, guard clauses, immutability, and reducing complexity.

Asma Hafeez KhanMay 24, 20265 min read
csharpdotnetclean-codereadabilitybest-practices
Share:𝕏

Writing Clean Code with C# — Readability & Simplicity

Clean code is code that communicates intent clearly, has no surprises, and is easy to change. It is a craft: deliberate and practiced, not a one-time event.


Meaningful Names

C#
// BAD — abbreviations and generic names
int d = 7;
List<string> lst = new();
void Proc(Customer c) { }

// GOOD — names reveal intent
int daysUntilExpiry = 7;
List<string> failedValidationMessages = new();
void SendWelcomeEmail(Customer newCustomer) { }

// BAD — misleading names
bool isNotValid = !customer.IsValid();
if (!isNotValid) { }   // double negative, confusing

// GOOD
bool isValid = customer.IsValid();
if (isValid) { }

// BAD — generic type names
public class Data { }
public class Manager { }
public class Handler { }

// GOOD — domain-specific names
public class OrderSummary { }
public class PaymentProcessor { }
public class InvoiceGenerator { }

Small, Focused Methods

C#
// BAD — one method doing everything
public async Task ProcessOrderAsync(Order order)
{
    // validate
    if (order.Items.Count == 0) throw new InvalidOperationException("No items");
    if (order.CustomerId <= 0)  throw new InvalidOperationException("Invalid customer");

    // calculate
    decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity);
    decimal tax = subtotal * 0.20m;
    decimal total = subtotal + tax;

    // save
    order.Total = total;
    await _db.Orders.AddAsync(order);
    await _db.SaveChangesAsync();

    // notify
    await _email.SendAsync(order.CustomerEmail, $"Order confirmed: £{total}");
}

// GOOD — each method has one responsibility
public async Task ProcessOrderAsync(Order order)
{
    ValidateOrder(order);
    order.Total = CalculateTotal(order);
    await _repository.SaveAsync(order);
    await _notifier.SendConfirmationAsync(order);
}

private void ValidateOrder(Order order)
{
    if (order.Items.Count == 0)
        throw new InvalidOperationException("Order must have at least one item");
    if (order.CustomerId <= 0)
        throw new InvalidOperationException("Order must have a valid customer");
}

private decimal CalculateTotal(Order order)
{
    decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity);
    return subtotal * 1.20m;   // includes 20% VAT
}

Guard Clauses (Early Returns)

C#
// BAD — deep nesting
public string GetUserDisplayName(int userId)
{
    var user = _db.Users.Find(userId);
    if (user != null)
    {
        if (user.IsActive)
        {
            if (!string.IsNullOrEmpty(user.DisplayName))
            {
                return user.DisplayName;
            }
        }
    }
    return "Unknown";
}

// GOOD — guard clauses exit early, happy path at the bottom
public string GetUserDisplayName(int userId)
{
    var user = _db.Users.Find(userId);
    if (user is null) return "Unknown";
    if (!user.IsActive) return "Unknown";
    if (string.IsNullOrEmpty(user.DisplayName)) return "Unknown";
    return user.DisplayName;
}

Avoid Magic Numbers and Strings

C#
// BAD — magic numbers with no explanation
if (password.Length < 8)
    throw new ArgumentException("Too short");

await Task.Delay(30000);

var discount = price * 0.15m;

// GOOD — named constants communicate intent
private const int MinPasswordLength = 8;
private const int RetryDelayMs = 30_000;   // underscore for readability
private const decimal LoyaltyDiscountRate = 0.15m;

if (password.Length < MinPasswordLength)
    throw new ArgumentException($"Password must be at least {MinPasswordLength} characters");

await Task.Delay(RetryDelayMs);

var discount = price * LoyaltyDiscountRate;

Immutability

C#
// Prefer immutable data — fewer moving parts, easier to reason about

// Mutable record (avoid)
public class Order
{
    public int Id { get; set; }
    public decimal Total { get; set; }
}

// Immutable record (prefer) — use C# record types
public record Order(int Id, decimal Total, DateTime CreatedAt);

// Create modified copies (non-destructive mutation)
var original = new Order(1, 100m, DateTime.UtcNow);
var updated  = original with { Total = 150m };   // new instance

// Immutable collections
using System.Collections.Immutable;
ImmutableList<string> tags = ImmutableList.Create("api", "orders");
ImmutableList<string> withNew = tags.Add("urgent");   // new list

// init-only properties (C# 9+) — set once, then readonly
public class CustomerProfile
{
    public required string Email { get; init; }
    public required string Name  { get; init; }
}

DRY vs DAMP in Tests

C#
// DRY (Don't Repeat Yourself) — in production code
// DRY in tests creates hidden coupling and hard-to-read tests
// DAMP (Descriptive And Meaningful Phrases) — in test code

// BAD (overly DRY test):
[Fact]
public void CreateOrder_Valid_Succeeds()
{
    var order = BuildTestOrder();   // what's in here? go find it
    var result = _service.Create(order);
    Assert.True(result.IsSuccess);
}

// GOOD (DAMP test — explicit, self-documenting):
[Fact]
public void CreateOrder_WithValidItems_ReturnsSuccessResult()
{
    var order = new Order(
        CustomerId: 42,
        Items: [new OrderItem("SKU-001", Quantity: 2, UnitPrice: 9.99m)],
        DeliveryAddress: "123 Test Street"
    );

    var result = _service.Create(order);

    Assert.True(result.IsSuccess);
    Assert.Equal(19.98m, result.Value.Total);
}

Interview Answer

"Clean code is about communication: the next developer who reads this code should understand intent without a comment. Key practices: names reveal purpose (daysUntilExpiry, not d); methods do one thing (single responsibility makes them easy to test and change); guard clauses eliminate deep nesting (exit early for invalid cases, happy path at the bottom); named constants replace magic numbers (reader understands why the number exists); immutability reduces the surface area of bugs (immutable records, init-only properties). DAMP test code is preferable to DRY test code — tests should be explicit and self-documenting rather than abstracting test data into shared helpers. Clean code is not about following rules mechanically; it is about making the code's intent visible."

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.