What Is Clean Code? Readability, Cost of Change and the 2025 Second Edition
Clean code is code that works and is also cheap to read and change: in Bookstore's legacy OrderService the same cart yields four different totals because of the Turkish I trap, a drifted copy of the pricing logic and DateTime.Now. The 2025 second edition rewrote the book into Code, Design, Architecture and Craftsmanship parts; this series cleans the 609-line file step by step.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's order module has been in production since 2017. Nobody wants to touch it, because "it works". The program below processes the same cart for the same customer twice: once with the machine's culture set to English (en-US) and once to Turkish (tr-TR). Each time it prints the price the cart page shows (GetQuote) and then the amount actually charged to the customer (ProcessData). Customer 1 is a VIP (Very Important Person; the most valuable customer segment here), there is no coupon, shipping type 1 (standard).
using System.Globalization;
using Bookstore.Legacy;
var cart = new Dictionary<string, int>
{
["978-0441013593"] = 1, // Dune, print book
["978-0553380163"] = 1 // A Brief History of Time, audiobook
};
foreach (string culture in new[] { "en-US", "tr-TR" })
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
Db.Reset();
var service = new OrderService();
decimal quote = service.GetQuote(1, cart, null, 1);
int orderId = service.ProcessData(1, cart, null, false, false, 1);
Order order = service.GetOrder(orderId);
Console.WriteLine($"{culture} cart page: {quote.ToString(CultureInfo.InvariantCulture)} charged: {order.Total.ToString(CultureInfo.InvariantCulture)}");
}
Console.WriteLine($"Run on: {DateTime.Now.DayOfWeek}");
You will see four numbers. How many of them should be equal? How many actually are? And one more question: would the result change if you ran this program on a Tuesday?
Same Cart, Four Different Numbers
The real output:
en-US cart page: 731.50 charged: 690.52
tr-TR cart page: 770.0 charged: 727.65
Run on: Saturday
Logically there should be one number. There are four, and none of them produced an exception, a warning or a log line. The causes show up one by one when you read the code, but each one hides in a different place:
The VIP discount is checked with c.Type.ToUpper() == "VIP". The customer's type is stored as "vip". A parameterless ToUpper() uses the current culture, and in Turkish the upper case of a lowercase "i" is "İ". The result is "VİP", the comparison returns false, and a VIP customer silently loses the 5 percent discount on a server set to Turkish. We met the Turkish I trap in the Strings post of the C# Fundamentals series; here it breaks a live business rule.
GetQuote, which the cart page uses, is a copy of the pricing logic in ProcessData. Over the years the copy drifted from the original: it knows nothing about the 10 percent audiobook discount or the weekend discount. So the customer sees 731.50 in the cart and is charged 690.52.
The weekend discount looks at DateTime.Now. I got this output on a Saturday; on a Tuesday the same program prints different numbers. The behavior depends on the calendar, and only someone who reads the method line by line will notice.
One more detail: 675 − 47.25 = 627.75, and with 10 percent tax that is 690.525. You see 690.52 in the output because Math.Round uses banker's rounding (MidpointRounding.ToEven) by default. That is not a bug, but nothing in the code shows that anyone chose it on purpose.
These four numbers sum up what this series is about. The code works: it compiles, takes orders and charges money. But understanding what it does is expensive and changing it is risky. Clean Code (Robert C. Martin's book, first published in 2008 with a second edition in 2025, and the approach it named) is about that second problem.
Code Is Read Far More Than It Is Written
In the 2008 edition of Clean Code, Robert C. Martin (the programmer known as "Uncle Bob", one of the signatories of the Agile Manifesto) says the time spent reading code is well over ten times the time spent writing it. It is his observation, not a measurement, but it is easy to confirm in daily work. When you want to add a new coupon, you first have to find where coupons are handled:
$ grep -n 'WELCOME10\|SUMMER25' OrderService.cs
85: if (strCoupon.ToUpper() == "WELCOME10")
87: else if (strCoupon.ToUpper() == "SUMMER25")
185: if (coupon.ToUpper() == "WELCOME10") tmp2 = tmp * 0.1m;
186: if (coupon.ToUpper() == "SUMMER25") tmp2 = 25;
Two places. But they don't do the same thing: one returns -4 and stops the order on an invalid coupon, the other silently ignores it. Adding the new coupon is one line, but deciding which behavior is right means reading two methods, their callers and probably talking to the product owner. Most of the cost of a change is not writing, it is understanding.
The second edition keeps this idea: its first chapter still has a section titled "We Read More Than We Write" and the Boy Scout Rule (leave the campground cleaner than you found it). The book's real claim is that code is written for the next reader, and the next reader is usually you, six months from now.
The Difference Between "Works" and "Changeable"
In Clean Architecture (Clean Architecture: A Craftsman's Guide to Software Structure and Design, 2017), Martin talks about the two values of software: doing what is needed today, and being cheap to turn into what will be needed tomorrow, in other words its structure. The architecture part of the second edition also opens with a chapter titled "The Two Values of Software". Clean Code is the code-level side of that second value. OrderService has delivered the first value for years. It has lost the second: adding a coupon means touching two methods, and changing a discount rule means thinking about the calendar and the server's culture.

The curve in the figure is schematic, not a measurement. Martin Fowler (author of Refactoring and Patterns of Enterprise Application Architecture) wrote this idea up in 2007 as the Design Stamina Hypothesis and deliberately called it a hypothesis: neither design quality nor productivity can be measured objectively, so it cannot be proven. The idea is this: code that spends no time on design and readability moves faster at first. As every change makes the next one a bit harder to read, that advantage melts away. After a certain point, the one Fowler calls the design payoff line, code that has been kept clean changes faster and produces fewer bugs. Martin describes the same thing in the 2008 edition as the total cost of owning a mess: as the team grows, productivity drops, because every new line is written on top of the old mess.
The practical lesson: for a short-lived script, investing in cleanliness may not pay off, because you will never reach the line. An order module that lives for years crossed it long ago. For OrderService the question is not "is it worth cleaning" but "how much interest are we paying every day".
What Does Clean Code Mean?
In the first chapter of the 2008 edition, Martin doesn't define the term alone; he puts the answers of Bjarne Stroustrup (creator of C++), Grady Booch (one of the architects of UML (Unified Modeling Language)), Dave Thomas (founder of OTI (Object Technology International)), Michael Feathers (author of Working Effectively with Legacy Code), Ron Jeffries and Ward Cunningham (both among the founders of XP (Extreme Programming); Cunningham also invented the wiki) side by side. What they have in common: the code doesn't surprise the reader, each piece does one thing well, there is no duplication, there are tests, and you can tell the author cared. None of them talks about line counts, brace placement or a particular language feature.
That is why Clean Code is not a style guide. Indentation, line length and capitalization are team decisions enforced by a tool (we will cover that in the upcoming Coding Conventions and Team Standards series). Clean Code asks harder questions: how many things does this method do, does this name reveal intent, where should this error be handled, why would this class change?
Let me also say this up front: read as a list of rules, Clean Code is very easy to misunderstand. Sentences like "functions shouldn't exceed 4 lines", taken out of context, can produce code that is harder to read. qntm's (the pen name of programmer and science fiction writer Sam Hughes) 2020 critique "It's probably time to stop recommending Clean Code" focuses on exactly that, on example code in the book that contradicts its own rules. The long exchange between John Ousterhout (Stanford professor, creator of the Tcl language and author of A Philosophy of Software Design) and Martin in 2024–2025 is about method length and comments as well. In this series I will present the rules with their reasons and their limits; we will cover the whole debate separately in post 13.
From 2008 to 2025: What Changed in the Second Edition?
The first edition came out in August 2008 (copyright 2009), 464 pages and 17 chapters. The examples were almost entirely Java; the final chapters were case studies in which real code such as SerialDate was cleaned step by step. The second edition was published by Addison-Wesley in autumn 2025. The publisher's own pages give two dates, 30 September and 8 October 2025, with a 2026 copyright; InformIT (Pearson's online bookstore) lists 672 pages. The publisher presents it as a comprehensive rewrite of the first edition.
| Topic | 1st edition (2008) | 2nd edition (2025) |
|---|---|---|
| Structure | 17 chapters; the last four are case studies and Smells and Heuristics | 4 parts: Code, Design, Architecture, Craftsmanship |
| Languages | Mostly Java | Java, JavaScript, Go, Python, Clojure, C# and C |
| Functions | One chapter: Functions | Clean Functions, Function Heuristics, The Clean Method, One Thing, Be Polite |
| Error handling | Its own chapter | "Prefer Exceptions to Returning Error Codes" inside Function Heuristics |
| Boundaries | Its own code-level chapter | Clean Boundaries in the Architecture part |
| Testing | Unit Tests | Testing Disciplines, Clean Tests, Acceptance Testing |
| Design | Emergence (Simple Design) | Simple Design, SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion), Component Principles, Continuous Design |
| New | — | A chapter on programming with AI (Artificial Intelligence) and LLMs (Large Language Models), the Ousterhout debate as an appendix |
The headings in the table come from the table of contents Pearson publishes. A few things stand out. Functions and reading order went from one chapter to five; the book's center of gravity is still the method level. SOLID and the component principles are now in the same book; Martin covered them in separate books before. There is a chapter titled "AIs, LLMs, and God Knows What". And at the end of the book his exchange with Ousterhout appears as an appendix called "The Clean Code Debate", so the criticism has been brought inside the book. In that debate Martin says he is "more balanced" in the second edition about how far to break methods down.
Throughout the series I will use concept names, not chapter numbers. The tables of contents on retailer sites don't agree on the numbers and they may change again in the next edition; "Command Query Separation" won't.
The Patient of This Series: the Legacy OrderService
Throughout the series we will clean a single file: Bookstore's OrderService.cs. 609 lines; the OrderService class itself is 462 lines with 20 methods. The file holds 11 more types: Order, OrderItem, Book, Customer, Address, City, a static Db that keeps the data in memory, a Mailer that writes e-mails to the console, a generic interface nobody uses, and Vendor.Payments.PaymentClient and ChargeResult, which imitate the payment provider's SDK (Software Development Kit). The code starts with #nullable disable, uses a block-scoped namespace, and compiles on .NET 10 without a single warning.
The main character of the file is the 136-line ProcessData. Here it is in full; read it slowly and keep counting "how many things does this method do":
// Processes the order
// 03.05.2019 A.K. - added coupons
// 17.11.2020 M.T. - added express shipping
// 09.02.2022 A.K. - added VIP discount
// 21.06.2023 S.Y. - added weekend discount
public int ProcessData(int cId, Dictionary<string, int> d, string strCoupon, bool flag, bool sendMail, int t)
{
LastError = null;
Customer c = null;
foreach (var x in Db.Customers)
{
if (x.Id == cId)
{
c = x;
}
}
if (c != null)
{
if (d != null && d.Count > 0)
{
if (c.IsBlocked == false)
{
Order o = new Order();
o.Id = Db.NextOrderId++;
o.Customer = c;
o.Date = DateTime.Now;
o.Status = 0;
o.Items = new List<OrderItem>();
decimal tmp = 0;
foreach (var kv in d)
{
Book b = Db.Books.FirstOrDefault(x => x.Isbn == kv.Key);
if (b == null)
{
LastError = "Book not found: " + kv.Key;
return -2;
}
if (b.Stock < kv.Value)
{
LastError = "Not enough stock: " + b.Title;
return -3;
}
OrderItem oi = new OrderItem();
oi.Book = b;
oi.Qty = kv.Value;
// calculate the price
if (b.Type == "ebook")
oi.Price = b.Price * 0.8m;
else if (b.Type == "audio")
oi.Price = b.Price * 0.9m;
else
oi.Price = b.Price;
tmp += oi.Price * oi.Qty;
o.Items.Add(oi);
}
// discount
decimal tmp2 = 0;
if (strCoupon != null && strCoupon != "")
{
if (strCoupon.ToUpper() == "WELCOME10")
tmp2 = tmp * 0.1m;
else if (strCoupon.ToUpper() == "SUMMER25")
tmp2 = 25;
else
{
LastError = "Invalid coupon";
return -4;
}
}
if (c.Type.ToUpper() == "VIP")
{
tmp2 += tmp * 0.05m;
}
if (o.Items.Count > 5) tmp2 += 15;
if (DateTime.Now.DayOfWeek == DayOfWeek.Saturday || DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
tmp2 += tmp * 0.02m;
// shipping
decimal k = KargoUcretiHesapla(tmp - tmp2, t, c.Address.City.Name);
if (flag) k += 12.5m; // gift wrap
o.Discount = tmp2;
o.Shipping = k;
o.Total = Math.Round((tmp - tmp2) * 1.1m + k, 2); // 10% tax
// payment
try
{
var r = pc.Charge(c.CardToken, (long)(o.Total * 100), "TRY");
if (r.Code != "00")
{
LastError = "Payment failed: " + r.Message;
o.Status = 9;
Db.Orders.Add(o);
return -5;
}
o.PaymentRef = r.TransactionId;
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return -99;
}
// decrease stock
foreach (var oi in o.Items) { oi.Book.Stock = oi.Book.Stock - oi.Qty; }
o.Status = 1;
Db.Orders.Add(o);
cnt++;
// add points
c.Points = c.Points + (int)(o.Total / 10);
if (c.Type == "vip") c.Points += 50;
if (sendMail)
{
StringBuilder sb = new StringBuilder();
sb.Append("Dear " + c.Name + ",\n");
sb.Append("Your order #" + o.Id + " has been received.\n");
foreach (var oi in o.Items)
sb.Append(" - " + oi.Book.Title + " x" + oi.Qty + " = " + (oi.Price * oi.Qty).ToString("0.00") + "\n");
sb.Append("Shipping: " + o.Shipping.ToString("0.00") + "\n");
sb.Append("Total: " + o.Total.ToString("0.00") + " TRY\n");
Mailer.Send(c.Email, "Order #" + o.Id, sb.ToString(), false);
}
log.AppendLine(DateTime.Now + " order " + o.Id + " total " + o.Total);
if (debug) Console.WriteLine(log.ToString());
return o.Id;
}
else
{
LastError = "Customer blocked";
return -6;
}
}
else
{
LastError = "Empty cart";
return -1;
}
}
else
{
LastError = "Customer not found";
return -7;
}
}
The signatures and lengths of the other members:
public class OrderService
{
public static string LastError;
public static int cnt = 0;
private PaymentClient pc = new PaymentClient("merchant-001", 30);
private Dictionary<string, object> cache = new Dictionary<string, object>();
StringBuilder log = new StringBuilder();
public bool debug = false;
public int ProcessData(int cId, Dictionary<string, int> d, string strCoupon, bool flag, bool sendMail, int t) // 136 lines
public decimal GetQuote(int cId, Dictionary<string, int> d, string coupon, int t) // 24
public decimal KargoUcretiHesapla(decimal amount, int t, string city) // 20
public bool CancelOrder(int id, bool refund, bool notify) // 25
public Order GetOrder(int id) // 6
public List<Order> GetOrders(int cId) // 6
public bool IsValid(Order o) // 11
public bool CheckStock(string isbn, int qty) // 11
public bool TryCalc(Order o, out decimal subtotal, out decimal tax, out string err) // 8
public bool ShipOrder(int id, bool express) // 22
public int CalcPoints(Customer c) // 8
public int CreateCustomer(string name, string email, string street, string city, string zip,
string country, string phone, string type, bool newsletter) // 18
public string Report(DateTime from, DateTime to, bool csv) // 16
public string FaturaOlustur(int orderId) // 22
public void UpdateStatus(int id, int s) // 20
public int ImportBooks(string path) // 30
private decimal OldCalcDiscount(decimal total, int itemCount) // 5
public void SendMail(string to, string subject, string body, bool html) // 8
public object GetFromCache(string key) // 5
public void ClearAll() // 4
}
This code is not deliberately exaggerated. Every part of it comes from habits I have seen in real projects: methods that return negative numbers as error codes, a static LastError, status values from 0 to 9 whose meaning lives only in a comment, methods that pick behavior through bool parameters, Turkish and English names mixed together, business rules that were copied and then drifted apart. Nobody wrote it like this in one go; the dated comments at the top show that everyone who touched it made "a small change".
The measurements say the same. With SonarAnalyzer's (the Roslyn analyzer package SonarSource writes for C#) S3776 (Cognitive Complexity) rule and .NET's CA1502 rule (CA: Code Analysis) turned on, the build reported:
OrderService.cs(31,20): warning S3776: Refactor this method to reduce its Cognitive Complexity from 92 to the 15 allowed.
OrderService.cs(31,20): warning CA1502: 'ProcessData' has a cyclomatic complexity of '27'. Rewrite or refactor the code to decrease its complexity below '26'.
Cognitive Complexity 92, with a default threshold of 15. I will explain how this number is calculated in the functions post; for now, take it as a sign of how much load four nested levels of if put on the reader.
What We Will Clean, and Where
Every problem in the file is the subject of a post:
- The names (
d,tmp2,k,t,strCoupon,KargoUcretiHesapla): the naming post. - The 136-line method, the four-level
ifpyramid, theflagandsendMailparameters, the hidden side effect inCheckStock: the functions post. - Changelog comments, the commented-out
ProcessDataV2, the "do not delete!" warning: the comments post. - Twelve types in one file, a one-line LINQ (Language Integrated Query) chain: the formatting post.
string Type,int Status, public fields,o.Customer.Address.City.Name: the objects and data structures post.- Return codes from
-1to-99, swallowed exceptions, methods that returnnull: the error handling post. PaymentClientandChargeResult.Code == "00"leaking everywhere: the boundaries post.- One 462-line class: the classes post. Then tests, simple design, a smell catalog, the debate, and in the last post the step-by-step cleanup where it all comes together.
A note: each post will focus on its own topic and keep its examples self-contained. We will see the final OrderService, protected by characterization tests (Michael Feathers' term: tests that pin down the code's current behavior as it is) and with every step as a separate commit, in post 14.
Common Pitfalls
Treating Clean Code as a style guide. A brace argument is over in ten minutes and settled by a tool. GetQuote drifting away from ProcessData is a design problem no formatter will ever catch. Spend your energy in code review on the second kind.
Turning the rules into dogma. If a rule like "methods must be at most 5 lines" becomes a team target, people split one job into ten tiny pieces and the reader gets tired of jumping around the file. The purpose of a rule is to reduce the reader's load; if it increases the load, it is being applied in the wrong place.
Wanting to rewrite everything. The first reflex when you see messy code is "let's rewrite it from scratch". But rules inside OrderService such as banker's rounding or the 10 lira shipping discount for Istanbul are written down nowhere; the team that rewrites it will only rediscover them when customers complain. This series' method is to pin the behavior down with tests first and then clean in small steps.
Mistaking the Boy Scout Rule for a big cleanup PR (pull request). The rule says "leave the place you touched a bit cleaner", not "reformat every file you touched". A thousand-line formatting PR can't be reviewed and buries the git blame history.
Seeing clean code as the enemy of performance, or the opposite, never thinking about performance at all. Casey Muratori's (a game developer who writes about performance-aware programming) 2023 article "Clean Code, Horrible Performance" measures how polymorphism and small virtual calls can carry a serious cost in hot loops. That matters in code that runs millions of times per second, not in an order module. We will talk in post 13 about where to measure and bend a rule on purpose.
When Not to Bother This Much
A data migration script that runs once and is thrown away, a prototype written to test an idea, or hackathon code never reaches the design payoff line. Readable names are still free, but splitting that code into layers, wrapping it in tests and shrinking every method is an investment that won't pay back. The one thing to watch for is the prototype quietly moving to production: OrderService probably started in 2017 as "let's leave it like this for now" too.
A Small Experiment
★☆☆ Run the program with InvariantCulture instead of tr-TR and watch the VIP discount come back. Then replace the ToUpper() calls with string.Equals(c.Type, "vip", StringComparison.OrdinalIgnoreCase) and verify that you get the same "charged" value in both cultures.
★★☆ Read ProcessData and write down, in order, the jobs it does (find the customer, validate the cart, price it…). How many items did you get? For each item answer "if this job changes, which lines do I touch?"
★★★ Write a small loop that finds the conditions under which GetQuote and ProcessData give different results for the same cart: iterate over the four books, three customers, two coupons and three shipping types, and print the differences. Every difference you find will become a line in the characterization tests we will write later.
In the next post we start the first cleanup with names: we will rename names like d, tmp2 and k using the domain language, look at .NET's naming guidelines, and look for an answer to the Turkish-or-English question in a code base where ProcessData sits next to KargoUcretiHesapla and FaturaOlustur.