The Clean Code Debate: Ousterhout, Muratori and the Limits of the Rules
Ousterhout and Martin agree on the goal, reducing the reader's load; they part on how far to split methods, the value of comments and TDD. The post summarizes the 2024–25 debate, Muratori's performance critique (repeated in C# with BenchmarkDotNet) and qntm's critique of the book from each side's own sources.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First: Which One Is Cleaner?
Bookstore's shipping fee rule is three sentences: standard shipping is 39.90 TL, orders of 500 TL or more ship free, paid shipments to Istanbul get 10 TL off; express shipping is a flat 89.90 TL. Two implementations of the same rule:
public sealed class ShallowShippingCalculator
{
private decimal amount;
private string city = "";
private decimal fee;
public decimal Calculate(decimal orderAmount, ShippingMethod method, string destinationCity)
{
SetInputs(orderAmount, destinationCity);
if (method == ShippingMethod.Express) ApplyExpressFee();
else CalculateStandardFee();
return fee;
}
private void SetInputs(decimal orderAmount, string destinationCity)
{
amount = orderAmount;
city = destinationCity;
}
private void ApplyExpressFee() => fee = 89.90m;
private void CalculateStandardFee()
{
ApplyBaseFee();
ApplyFreeShippingIfEligible();
ApplyIstanbulReductionIfEligible();
}
private void ApplyBaseFee() => fee = 39.90m;
private void ApplyFreeShippingIfEligible()
{
if (IsEligibleForFreeShipping()) fee = 0m;
}
private bool IsEligibleForFreeShipping() => amount >= 500m;
private void ApplyIstanbulReductionIfEligible()
{
if (city == "Istanbul" && fee > 0m) fee -= 10m;
}
}
public static class ShippingFee
{
private const decimal FreeShippingThreshold = 500m;
// Standard shipping 39.90; free from 500 TL; 10 TL off paid shipments to Istanbul.
public static decimal For(decimal amount, ShippingMethod method, string city) => method switch
{
ShippingMethod.Express => 89.90m,
_ when amount >= FreeShippingThreshold => 0m,
_ when city == "Istanbul" => 29.90m,
_ => 39.90m,
};
}
A small program that compares them across 3 amounts, 2 cities and 2 shipping methods:
All 12 cases agree.
The first version is the rules "functions should be small, do one thing, be well named" applied to the letter: its longest method is four lines and every name says what it does. The second is a single switch expression and a comment. Which one is cleaner? One more question: can you tell why ApplyIstanbulReductionIfEligible checks fee > 0m without reading any other method?
That question sits right in the middle of a debate that ran from autumn 2024 to spring 2025. This post summarizes that debate, Casey Muratori's performance critique and qntm's critique of the book, from each side's own sources.
The Debate: Ousterhout and Martin
John Ousterhout is a computer science professor at Stanford and the author of APOSD (A Philosophy of Software Design, John Ousterhout, first edition 2018, second edition 2021). Between September 2024 and February 2025 he had a series of discussions with Robert C. Martin, some online and some in person, and they published the result as a joint text in the aposd-vs-clean-code repository on GitHub. The text covers three topics: method length, comments and TDD (Test-Driven Development). In the publisher's table of contents for the second edition of Clean Code, released in 2025, this text appears as an appendix called "The Clean Code Debate", with the same section headings.
To read the debate fairly you have to look at the starting point, because there they agree. For Ousterhout, the goal of software design is to make a system easy to understand and modify, and he measures complexity with two questions: how much information does a developer have to keep in their head, and how obvious is that information? Martin says he agrees with this approach and adds one thing: the person we want to help is not the one who writes the code but the programmer who reads someone else's code. So both are looking at the same goal: reducing the reader's load. Where they part is which rules get you there.
Deep Modules and Shallow Methods
Ousterhout's central concept is the deep module: a unit that does a lot behind a simple interface. A good method lets the reader learn a short interface instead of reading the implementation, and that is how it reduces load. As methods get smaller, each one hides less work, while their interfaces often get more complex; Ousterhout calls these shallow. The second problem is entanglement: if you have to read one method to understand how another works, the two are entangled. If you notice yourself flipping back and forth between methods while reading, that's a warning. Ousterhout's critique of Clean Code: when the book says "functions should be small, and smaller than that", it gives very strong, numeric advice, but almost no guidance for recognizing when you've gone too far.
Martin's answer is consistent too. He says the "two, three or four line functions" example in the book came from Sparkle, an applet he and Kent Beck wrote in 1999 to learn TDD, and that the book offers its recommendations for consideration rather than as absolute truth. He accepts that over-decomposition is possible; his yardstick is the "one thing" rule: if you can extract a part from a method that can be given a meaningful name and does less than the original, the method was doing more than one thing. When Ousterhout says the rule is vague and that anything can be named, Martin argues that, like any tool, it can be abused, and that in the end it comes down to judgment. He adds that his concern in 2008 was breaking the habit of giant functions from the early web, and that he wrote more balanced advice in the second edition. If he has to err, he prefers to err on the side of decomposition, because over-split methods can be inlined later.
Their shared summary is clear: both value modular design, both accept that over-decomposition is possible and that the first edition of Clean Code doesn't help much in recognizing it. They part on how far to decompose and how much entanglement matters: Martin thinks the benefits of decomposition can make up for the cost of entanglement, Ousterhout thinks they can't.
Look at the opening example with this in mind. Every method in ShallowShippingCalculator is shallow: ApplyBaseFee is a single assignment, its interface says no less than its implementation. And the methods are entangled: the fee > 0m check in ApplyIstanbulReductionIfEligible relies on the previous method having set fee to zero for free shipping. To know that, you have to read the call order and who writes which of the three fields when; that is exactly the Temporary Field smell from the previous post. ShippingFee.For is deep: one interface with the whole rule behind it, and the order is visible in the switch itself. But this isn't a conclusion Martin rejects; by his "meaningful extract" yardstick a method like ApplyBaseFee can't be defended either. The real disagreement is in a grayer zone: should you split a 40-line method into three 13-line ones? That's where the two authors really give different answers.
PrimeGenerator: The Example Where Everyone Made Mistakes
The most instructive part of the debate happens on shared code. The PrimeGenerator from the first edition of Clean Code (a Java class that generates the first N primes, adapted from Donald Knuth's literate programming example), split into eight small methods, became Ousterhout's target. Ousterhout showed that methods like isNotMultipleOfAnyPreviousPrimeFactor have hidden side effects and that their correctness depends on how a distant loop advances candidate. Martin agreed the internal decomposition was problematic; he said the example's goal was to show decomposition into classes, not into methods.
Ousterhout rewrote the algorithm as a single method with lots of comments. Martin wrote that those comments didn't help him, that one of them had a bug, and that he only understood "why the square is the starting point" after a long bike ride; Ousterhout fixed the bug and rewrote the comments. Then Martin wrote his own version, and Ousterhout, while agreeing it was readable, measured it: splitting the loop in two had replaced a single loop that exits as soon as the candidate is eliminated with one that walks the whole list at every step. By Ousterhout's measurement the result was a 3–4x slowdown. Martin admitted he hadn't profiled it and merged the loops; by his own measurement his new version finished a million primes in 440 ms, Ousterhout's in 561 ms. Ousterhout accepted the fix and noted that the method count had dropped from eight to four.
The lesson of this story isn't that one side was right. Both struggled to understand the same algorithm, neither one's first explanation was enough for the other, and a revision focused on producing small methods silently broke performance because nobody measured.
Comments and TDD
Comments are where they agree least, and they say so in their own summary. For Ousterhout, you can't define an abstraction without interface comments, because a method signature doesn't tell the whole contract; he believes missing comments cost far more time than wrong ones and estimates he would write 5–10 times more comment lines than Martin for the same code. He also sees very long method names as an awkward substitute for comments. Martin trusts most comments only once the code has verified them, says he has found most comments in the industry unhelpful, and prefers to move the information into the code where possible, for example into a long name. Where they agree: a public API (Application Programming Interface) needs documentation, and implementation comments are needed only when the code isn't obvious. The "write what the code can't say" rule from this series' comments post is exactly this common ground.
On TDD, Ousterhout accepts that his book's description of TDD was inaccurate but still thinks the approach pushes design into the background, and proposes "bundling": a few tens to a few hundred lines of code, then the tests for that code. Martin prefers TDD in short cycles. They agree that unit tests are indispensable and that bundling can produce results as good as TDD; they disagree on which gives better results in the average and the worst case. An interesting detail: in the second edition's table of contents, the Testing Disciplines chapter lists TDD, TCR (Test && Commit || Revert) and Small Bundles side by side.
Muratori: The Performance Bill
In February 2023 Casey Muratori attacked from a different angle in his article and video "Clean Code, Horrible Performance". He picked five Clean Code rules that affect code structure: polymorphism instead of if/switch, not knowing the internals of objects, small functions, functions that do one thing, and DRY (Don't Repeat Yourself). He wrote the classic Shape hierarchy in C++ and measured summing the areas. Writing the virtual-call version with a switch made it about 1.5 times faster, and with a coefficient table that knows the shapes' common structure, more than 10 times faster; in an extended example that also weights by corner count, the gap grew to about 15 times. He found DRY mostly acceptable.
I repeated the experiment in C# and .NET 10 with BenchmarkDotNet:
[MemoryDiagnoser]
public class ShapeAreaBenchmarks
{
private const int Count = 4096;
private Shape[] shapes = [];
private ShapeData[] data = [];
// GlobalSetup: the same 4096 random shapes are written to both arrays
[Benchmark(Baseline = true)]
public float VirtualCall()
{
float total = 0;
foreach (Shape shape in shapes) total += shape.Area();
return total;
}
[Benchmark]
public float SwitchOnKind()
{
float total = 0;
foreach (ShapeData shape in data) total += shape.Area();
return total;
}
[Benchmark]
public float CoefficientTable()
{
float total = 0;
foreach (ShapeData shape in data) total += ShapeData.Coefficients[(int)shape.Kind] * shape.Width * shape.Height;
return total;
}
}
public abstract class Shape
{
public abstract float Area();
}
public sealed class Circle(float radius) : Shape
{
public override float Area() => MathF.PI * radius * radius;
}
public readonly record struct ShapeData(ShapeKind Kind, float Width, float Height)
{
public static readonly float[] Coefficients = [1f, 1f, 0.5f, MathF.PI];
public float Area() => Kind switch
{
ShapeKind.Square or ShapeKind.Rectangle => Width * Height,
ShapeKind.Triangle => 0.5f * Width * Height,
_ => MathF.PI * Width * Height,
};
}
Apple M5, .NET 10.0.13, Arm64 RyuJIT
| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio |
|----------------- |----------:|----------:|----------:|------:|--------:|----------:|------------:|
| VirtualCall | 10.182 us | 0.2029 us | 0.4324 us | 1.00 | 0.06 | - | NA |
| SwitchOnKind | 3.032 us | 0.0233 us | 0.0218 us | 0.30 | 0.01 | - | NA |
| CoefficientTable | 2.586 us | 0.0312 us | 0.0243 us | 0.25 | 0.01 | - | NA |
Square, Rectangle and Triangle follow the same pattern as Circle. Summing the areas of 4096 shapes took 10.2 µs with virtual calls, 3.0 µs with a switch and 2.6 µs with the table; the ratios differ from Muratori's C++ results, but the direction is the same. A caveat: the two versions differ not only in the call mechanism but also in memory layout. One is an array of references to objects scattered on the heap, the other is structs sitting next to each other; this measurement doesn't separate how much of the difference comes from which. That is Muratori's real point anyway: code that knows its data layout is faster than code that doesn't.
Martin had a written debate with Muratori in the unclebob/cmuratori-discussion repository on GitHub and accepted the core of the analysis: these structures are not efficient at the nanosecond level. His objection was about context: he said the vast majority of software uses a small fraction of the processor, that even within one application modules working at the nanosecond, microsecond and millisecond level sit side by side, and that in his work he prefers programmer time over machine time. Muratori's closing summary states the disagreement honestly: they disagree on how often implementation details should be hidden and on how important the computer is. Muratori, for his part, argues that much of the software we use every day (compilers, browsers, office applications) falls into the category where performance matters.
Look at the scale from Bookstore's point of view: the difference per shape is about 1.7 nanoseconds. An order request goes to the database and the payment provider, so it's measured in milliseconds; turning OrderPricing from a switch into polymorphism, or the other way around, won't change that request's duration measurably. But the inner loop of an import, a report or a parser that processes millions of rows is a different story. The rule is simple: measure first, then bend the rule deliberately. We covered how to measure in the C# Advanced series post "Performance in C#: Allocations, Structs and Measuring with BenchmarkDotNet". The second edition's table of contents has a section titled "But What About Performance?" under the Objects and Data Structures chapter; Martin brought the topic into the book too.
qntm and the Second Edition's Answer
In 2020 the writer known as qntm objected to the book from a more fundamental place in the post "It's probably time to stop recommending Clean Code": the quality of the example code. In the SetupTeardownIncluder example at the end of the functions chapter, qntm showed that almost all of the fifteen private methods pass data to each other through member variables instead of parameters; in other words, they work through the hidden side effects the same chapter calls "lies". qntm also pointed out that one-line methods called from a single place don't meet the book's own yardstick of extracting something whose name isn't merely a restatement of its implementation. To be fair, qntm calls advice such as avoiding side effects, separating commands from queries and choosing descriptive names sound in the same post. The objection is that good advice and questionable or dated advice are mixed in the same book with the same tone. qntm also says the book is built only on Java and only on OOP (Object-Oriented Programming), and that the formatting chapter today reduces to one sentence: pick a formatter, automate it, forget about it.
The second edition seems to answer some of these criticisms directly, but it's important to know the limits here: I'm relying on the publisher's description and table of contents, not on the whole book. According to the publisher the second edition is a full rewrite, and the examples are given in JavaScript, Go, Python, Clojure, C# and C as well as Java. Headings such as "What Are Large Functions Anyway?" and the inclusion of the whole Ousterhout debate as an appendix match Martin's statement in the debate that he wrote more balanced advice. How the SetupTeardownIncluder example is handled in the second edition can't be inferred from the table of contents; I'm not claiming anything I haven't verified myself.
The Principle Beneath the Rules
Put the three critiques side by side and a common denominator appears, and it's the same as Clean Code's own goal: don't put a burden on the reader. The rules are shortcuts to that goal; when a shortcut conflicts with the goal, the goal wins. These are the questions I use to turn that into everyday decisions:
- Does the method I extracted save the reader from reading the implementation, or does it force them to flip between two places?
- Do you need to know which fields are written in which order to understand this method? If so, the methods are entangled.
- Can the code tell the "why"? If not, a short and correct comment beats a long name.
- Is this code really on a hot path? If not, readability wins; if so, measure first, then write code that knows its data layout and leave the reason in a comment.
Common Pitfalls
Reading the debate to declare a winner. Both authors stepped back on some points, both made mistakes in PrimeGenerator. What's valuable is the arguments, not the outcome.
Generalizing "Clean Code is slow". Muratori's measurement is real, but the duration of an order service is set by the database and the network. Breaking the design for performance without measuring the hot path is as wrong as applying the rule without measuring.
Reading Ousterhout as "write long methods". A deep module doesn't mean a long method; it means a unit that hides a lot of work behind a simple interface. A 300-line method may have a simple interface, but if its inside can't be read, it fails too.
Treating comments as a cure-all. What Ousterhout defends are interface and "why" comments; comments that repeat the code line by line are what both sides oppose.
Mixing up the editions. Most of the criticism targets the 2008 edition. When you discuss a quote, say which edition it comes from.
A Small Experiment
★☆☆ Take ShallowShippingCalculator from the opening and swap the call order of ApplyFreeShippingIfEligible and ApplyIstanbulReductionIfEligible. For which input does the result change? How many methods did you read to find out?
★★☆ Add a fourth benchmark to ShapeAreaBenchmarks: the same Shape[] array, but a version that reads Area from Shape as a non-virtual property. Does the difference come from the virtual call or from the memory layout?
★★★ Find the five longest methods in your own project. For each, answer Ousterhout's question (is the interface simpler than the implementation?) and Martin's question (can a part be extracted with a meaningful name?) separately. In how many methods do the answers conflict?
In this post we talked about the limits of the rules. In the last post of the series we apply all of them in one place: we clean up the legacy OrderService.cs while protecting it with characterization tests, with every step as a separate commit and the tests run at every commit.