Skip to main content

Clean Code Advice That Actually Hurts Maintainability

·1658 words·8 mins

A lot of “clean code” advice looks great on a slide and falls apart the moment you have to debug a real production bug. It optimizes for tiny functions, cute abstractions, and pretty code listings instead of the thing that actually matters: how fast someone can find and fix a problem in a big, imperfect system.

Maintainable code is not about how nice it feels to type. It’s about how painful it is to change when you’re tired, under time pressure, and not the original author.


What maintainability really means
#

When people say “maintainable code,” what they should mean is:

How quickly can a reasonably smart person, unfamiliar with this part of the codebase, safely make a change here without breaking something over there?

That includes future‑you who has forgotten all the clever details.

So the real goals are:

  • Make it fast to find the right slice of the system for a bug or feature.
  • Make it obvious what decisions were made along that path.
  • Make sure changes stay local instead of triggering whack‑a‑mole bugs elsewhere.

Whether your functions are all under 5 lines is basically irrelevant compared to those.


How code is actually read in the real world
#

The way books and blog posts present code is wildly different from how we explore real codebases.

In a book, you:

  • Start at the top of the listing.
  • Read line by line.
  • Flip the page when the typesetter tells you to.

In a real project, you almost never do this. Your workflow usually looks like:

  1. You get a bug report with:

    • An error message.
    • Some log output.
    • A wrong value on a screen.
  2. You search for that string in the code.

  3. You land in some function that prints or returns it.

  4. You use “Go to definition” / “Find callers” to walk up the call stack.

  5. You repeat until you’ve traced the slice of the application that matters.

You’re reading bottom‑up from a symptom, not top‑down from main().

If a codebase forces you to read it top‑to‑bottom to understand anything, that’s not “clean,” that’s a failure of design.


Where “Clean Code”‑style patterns go wrong
#

A lot of advice around “clean code,” SOLID, and design patterns gets applied in a way that looks like this:

  • Lots of tiny methods and tiny classes.
  • Deep inheritance hierarchies.
  • Interfaces and polymorphism for things that don’t need them.
  • Heavy emphasis on hiding details behind abstractions.

In moderation, those tools are fine. Overused, they give you:

  • Call stacks that bounce through ten layers to do something simple.
  • “Find implementations” returning a dozen nearly identical subclasses.
  • Logic so spread out you have to run the app and step through it just to see what actually happened.

That’s the opposite of maintainable.

To see this clearly, let’s walk through a concrete example.


The expense‑report bug: where did the 13 cents go?
#

Picture an expense‑report system. An employee submits meal receipts totaling 25.13, but the final report shows 25.00.

The rules for this system:

  • Some days are per diem days:
    • The employee gets a fixed daily amount (say, 25.00) regardless of receipts.
  • Other days are receipt days:
    • The employee gets reimbursed for the sum of the receipts.

You get a ticket:

“My receipts add up to 25.13, but I only got 25.00. Why?”

You start debugging:

  • You check the code where totals are computed.
  • You look for rounding or truncation to whole dollars.
  • You search for any place that might drop cents.

Nothing looks wrong. The math is fine. No obvious rounding.

Eventually, you discover the real problem:

  • The system decided that this was a per diem day.
  • It completely ignored the receipts and just used the per‑diem amount (25.00).
  • For that specific case, the decision to use per diem was wrong.

The bug isn’t in the arithmetic. It’s in the hidden decision logic.


Before: “clean” abstraction that hides the bug
#

Here’s a stripped‑down version of code written in a very “clean code”‑ish style.

// BEFORE

public class ExpenseReportService {

    private final ExpenseReportDAO expenseReportDAO;

    public ExpenseReportService(ExpenseReportDAO expenseReportDAO) {
        this.expenseReportDAO = expenseReportDAO;
    }

    public Money calculateTotal(Employee employee, LocalDate date) {
        MealExpenses expenses = expenseReportDAO.getMeals(employee, date);
        return expenses.getTotal();
    }
}

public interface MealExpenses {
    Money getTotal();
}

public class PerDiemMealExpenses implements MealExpenses {

    private final Money perDiemAmount;

    public PerDiemMealExpenses(Money perDiemAmount) {
        this.perDiemAmount = perDiemAmount;
    }

    @Override
    public Money getTotal() {
        return perDiemAmount;
    }
}

public class ReceiptMealExpenses implements MealExpenses {

    private final List<Receipt> receipts;

    public ReceiptMealExpenses(List<Receipt> receipts) {
        this.receipts = receipts;
    }

    @Override
    public Money getTotal() {
        return receipts.stream()
                .map(Receipt::amount)
                .reduce(Money.zero(), Money::add);
    }
}

public class ExpenseReportDAO {

    public MealExpenses getMeals(Employee employee, LocalDate date) {
        if (isPerDiemDay(employee, date)) {
            return new PerDiemMealExpenses(loadPerDiemAmount(employee, date));
        } else {
            return new ReceiptMealExpenses(loadReceipts(employee, date));
        }
    }

    private boolean isPerDiemDay(Employee employee, LocalDate date) {
        // complex business rules...
        return true;
    }

    private Money loadPerDiemAmount(Employee employee, LocalDate date) {
        // query config / policy tables...
        return new Money("25.00");
    }

    private List<Receipt> loadReceipts(Employee employee, LocalDate date) {
        // query receipts...
        return List.of();
    }
}

Why people call this “clean”
#

  • calculateTotal is a one‑liner.
  • The main code doesn’t know about per‑diem vs receipts.
  • All the “ugly” decision logic is nicely hidden behind MealExpenses.

From a book’s point of view, this is cute and composable.

From a maintainer’s point of view, it’s kind of a trap.

Why it’s painful to debug
#

When the total is wrong:

  • From calculateTotal, you cannot tell:
    • Whether per diem or receipts were used.
    • Which rule chose per diem.
  • You have to:
    • Jump into ExpenseReportDAO.
    • Understand isPerDiemDay.
    • Hope there’s logging, or run with a debugger and step through.

The abstraction doesn’t just hide implementation details; it hides the core decision you need to see to understand the bug.


After: explicit, debuggable, still sane
#

Let’s refactor this to prioritize actual maintainability:

  • Make the per‑diem vs receipts decision a first‑class value.
  • Keep the control flow in one obvious place.
  • Return enough information for debugging, not just a single number.
// AFTER

public class ExpenseReportService {

    private final ExpensePolicy policy;
    private final ReceiptRepository receiptRepository;

    public ExpenseReportService(ExpensePolicy policy,
                                ReceiptRepository receiptRepository) {
        this.policy = policy;
        this.receiptRepository = receiptRepository;
    }

    public ExpenseCalculationResult calculateTotal(Employee employee, LocalDate date) {
        ExpenseDecision decision = policy.decideFor(employee, date);

        Money total;
        if (decision.usePerDiem()) {
            total = decision.perDiemAmount();
        } else {
            List<Receipt> receipts = receiptRepository.findByEmployeeAndDate(employee, date);
            total = sum(receipts);
        }

        return new ExpenseCalculationResult(decision, total);
    }

    private Money sum(List<Receipt> receipts) {
        return receipts.stream()
                .map(Receipt::amount)
                .reduce(Money.zero(), Money::add);
    }
}

Supporting types:

public record ExpenseDecision(boolean usePerDiem,
                              Money perDiemAmount,
                              String reason) {
    public boolean useReceipts() {
        return !usePerDiem;
    }
}

public record ExpenseCalculationResult(ExpenseDecision decision,
                                       Money total) {}

public interface ExpensePolicy {
    ExpenseDecision decideFor(Employee employee, LocalDate date);
}

public class DefaultExpensePolicy implements ExpensePolicy {

    private final PerDiemConfig perDiemConfig;

    public DefaultExpensePolicy(PerDiemConfig perDiemConfig) {
        this.perDiemConfig = perDiemConfig;
    }

    @Override
    public ExpenseDecision decideFor(Employee employee, LocalDate date) {
        if (perDiemConfig.isPerDiemDay(employee, date)) {
            Money perDiem = perDiemConfig.amountFor(employee, date);
            return new ExpenseDecision(true, perDiem, "Per-diem day");
        }
        return new ExpenseDecision(false, Money.zero(), "Use receipts");
    }
}

What improved
#

  1. The decision is visible
    ExpenseDecision explicitly captures:

    • Was per diem used?
    • What per‑diem amount was applied?
    • Why did we choose that path?

    You can log this without spelunking internals:

    ExpenseCalculationResult result = service.calculateTotal(emp, date);
    log.info("Expense decision: {}", result.decision());
    
  2. The control flow is traceable

    The main method is a bit longer, but:

    • You immediately see the branch: if (usePerDiem) ... else ....
    • You see where receipts are fetched.
    • You see where totals are summed.

    That’s exactly how humans debug: follow the branch that ran.

  3. Debugging the 25.13 → 25.00 bug is now straightforward

    With the new design:

    • Log ExpenseDecision for the failing case.
    • If usePerDiem is true but it shouldn’t be:
      • The bug is in ExpensePolicy / PerDiemConfig.
    • If useReceipts is true and the sum is off:
      • The bug is in the receipts or their math.

    No detective work required just to figure out which path executed.


Before vs after: quick comparison
#

Aspect Before (“clean”) After (maintainable)
Decision visibility Hidden inside DAO and polymorphism Exposed via ExpenseDecision
Return value Single Money value ExpenseCalculationResult (decision + total)
Call site readability One‑liner, but blind Slightly longer, but explicit about branches
Debug strategy Step through internals / inspect subclasses Log and inspect decision + result
Tightness of abstraction Hides everything, including what you need to see Hides low‑level details, but surfaces high‑level decisions

The after version is “less clean” in the book sense, but much cleaner to maintain.


The real principles hiding under all this
#

Pulling it together, here are the principles this example shows off.

1. Optimize for bottom‑up reading
#

Write code so that someone can:

  • Start from a log line or a wrong value.
  • Search for it.
  • Walk up the call stack in a straight-ish line.

Avoid designs that force people to:

  • Jump across 10 tiny methods and 5 subclasses to see a simple decision.
  • Rely on runtime stepping just to find out which branch ran.

2. Expose important decisions
#

Abstractions should hide noise, not insight.

  • It’s fine to hide “how we hit the database.”
  • It’s not fine to hide “did we reimburse by receipts or by per diem?”

If a decision is central to understanding a bug, it should be:

  • Represented explicitly (like ExpenseDecision).
  • Easy to log.
  • Easy to pass around and test.

3. Design assuming you’ll be wrong
#

Your:

  • Requirements will be incomplete.
  • Edge cases will be missed.
  • Specs will change.

Maintainable code is not about preventing bugs; it’s about making bugs cheap to find and fix.

So:

  • Don’t over‑compress logic into clever one‑liners.
  • Don’t hide everything behind interfaces just because you can.
  • Do make it obvious what your code thought it was doing when it went wrong.

Final thought
#

“Clean” code that looks good in a PDF but makes bug‑hunting miserable is not clean in any sense that matters. If a refactor makes it harder to answer, “What did this code decide, and why?”, it’s a step backwards.

Write code for the person chasing a bug through your system a year from now. That person is very likely you.