Clean Code Without the Dogma โ What Actually Matters in Practice
Practical naming, function size, and abstraction decisions I've changed my mind about after years of writing production code.

Read "Clean Code" by Robert Martin when I was two years into programming. Treated it like scripture. Functions must be short. Names must be long and descriptive. Every conditional should be extracted into a well-named method. Comments are a sign of failure. I enforced these rules on myself and on anyone unfortunate enough to submit a pull request to a codebase I maintained.
Then I worked on production systems for a few more years and started noticing where the rules fell apart. Not that the book is wrong โ most of the principles are solid starting points. But applying them without judgment creates code that's hard to read in a different way. Instead of being unclear because it's messy, it's unclear because it's been abstracted into so many tiny pieces that you can't follow the flow.
What I want to share here isn't "Clean Code is bad" โ that take is as lazy as the dogmatic application of the rules. What I've settled on after several years is a set of practical guidelines that I actually follow when writing code, not just when talking about writing code.
Naming Is the Hardest Problem (and the Most Important)
Good naming does more for readability than any other single practice. Bad naming is the most common reason code is hard to understand. This part of Clean Code I agree with completely โ I just think the advice on how to name things needs more nuance.
The standard advice: names should be descriptive. calculateMonthlyRevenueForActiveUsers() instead of calc(). True. But there's a spectrum, and going too far toward verbose names creates its own readability problem.
// Too terse โ what does this mean?
const d = getD(u, p);
// Too verbose โ reads like a legal document
const dashboardDataFilteredByUserPermissionsAndDateRange =
getDashboardDataFilteredByUserPermissionsAndDateRange(
currentlyAuthenticatedUser,
selectedDateRangeFromFilterPanel
);
// About right โ clear without being exhausting
const dashboardData = getDashboardData(user, dateRange);
The third version communicates everything the reader needs. The function name tells you what it returns. The parameter names tell you what it's filtered by. Nobody needs the full filter logic spelled out in the variable name โ that's what the function body is for.
My rule of thumb for variable name length: proportional to scope. Loop counter in a three-line loop? i is fine. Everyone knows what i means in a for loop. Class field that persists for the lifetime of the object? More descriptive. Module-level constant? Very descriptive. The longer something lives and the more places it's referenced, the more important its name becomes.
// Short scope: terse names are fine
const sorted = users.filter(u => u.active).sort((a, b) => a.name.localeCompare(b.name));
// Long scope: descriptive names matter
class UserRepository {
private connectionPool: DatabaseConnectionPool;
private queryTimeoutMs: number;
private maxRetryAttempts: number;
}
One naming pattern that took me years to adopt: name booleans as questions. isLoading, hasPermission, canDelete, shouldRetry. When you read the conditional, it reads like English: if (hasPermission), while (shouldRetry). Small change. Noticeable improvement in readability, I think.
Function Names Should Describe the What, Not the How
sortUsersUsingQuickSort() tells me the implementation. sortUsersByJoinDate() tells me the result. The second is almost always what I want. If the sorting algorithm matters, document it inside the function. The caller cares about the outcome, not the mechanism.
The exception: when the "how" is the entire point. A function called binarySearch() is correctly named even though it describes the algorithm, because the algorithm IS the behavior the caller cares about.
Function Size โ Where I Diverge from the Book
Clean Code says functions should be small. Five lines, ideally. I followed this strictly for a while and ended up with code like this:
function processOrder(order: Order) {
validateOrder(order);
calculateTotal(order);
applyDiscount(order);
checkInventory(order);
chargePayment(order);
sendConfirmation(order);
updateAnalytics(order);
}
function validateOrder(order: Order) {
validateItems(order);
validateShipping(order);
validatePayment(order);
}
function validateItems(order: Order) {
checkItemsExist(order);
checkItemsInStock(order);
checkItemQuantities(order);
}
// ... 15 more functions, each 3-5 lines
Every function is small. The names are descriptive. The code is organized. And it's genuinely difficult to follow, because understanding what processOrder does requires bouncing between 20+ functions spread across the file. The logic is fragmented into pieces so small that the flow is invisible.
What I do now: keep functions short enough that they do one thing, but long enough that you can follow the logic without jumping around constantly. If a function is 30 lines but every line is relevant and the logic flows top to bottom, that's fine. If a function is 5 lines but each line calls another function that calls another function three levels deep, the short function isn't actually easier to understand.
async function processOrder(order: Order): Promise<OrderResult> {
// Validate
if (!order.items.length) {
return { success: false, error: 'No items in order' };
}
const unavailable = order.items.filter(item => !inventory.has(item.id));
if (unavailable.length) {
return { success: false, error: `Items unavailable: ${unavailable.map(i => i.name).join(', ')}` };
}
// Calculate pricing
const subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discount = order.couponCode ? await lookupDiscount(order.couponCode) : 0;
const total = subtotal - discount + calculateShipping(order.address);
// Charge and fulfill
const payment = await chargeCard(order.paymentMethod, total);
if (!payment.success) {
return { success: false, error: payment.error };
}
await updateInventory(order.items);
await sendConfirmationEmail(order.email, { items: order.items, total, trackingId: payment.id });
return { success: true, orderId: payment.id, total };
}
This is about 25 lines. It does one thing โ processes an order. The steps are visible in sequence. Comments mark the phases. Some things are extracted (lookupDiscount, calculateShipping, chargeCard) because they're genuinely separate concerns. Validation and pricing calculation are inline because they're simple and pulling them into separate functions would just scatter the logic.
The heuristic I use: extract a function when you can give the extracted piece a name that makes the calling code clearer. If the extracted function would be called doTheNextThingInTheProcess(), leave it inline.
Comments โ Not a Failure, Sometimes Essential
"Code should be self-documenting" is repeated so often that people feel guilty writing comments. I did. Deleted comments and replaced them with longer function names, convinced I was improving the code.
Some things cannot be expressed in code. The "why" behind a decision. The business context that makes a strange implementation necessary. The non-obvious constraint that explains a seemingly irrational choice.
// BAD comment โ repeats what the code says
// Increment counter by one
counter += 1;
// GOOD comment โ explains WHY
// Payment provider requires amounts in cents, not dollars.
// Their API returns a generic "invalid amount" error if you send decimals,
// which we spent two days debugging in production.
const amountInCents = Math.round(price * 100);
// GOOD comment โ documents a constraint
// Maximum 50 items per batch because the vendor API returns 413
// for payloads over ~2MB. 50 items keeps us safely under that.
const BATCH_SIZE = 50;
// GOOD comment โ explains a non-obvious workaround
// Safari doesn't support the :has() pseudo-class in this context
// when the parent is a flex container. Using JavaScript instead of CSS
// until Safari 19+ has sufficient market share. Track at:
// https://bugs.webkit.org/show_bug.cgi?id=XXXXX
The first comment is noise. The others capture knowledge that would be lost without them. No amount of function naming communicates "the vendor API returns 413 over 2MB." That's a comment or it's tribal knowledge that lives in someone's head until they leave the team.
My rule: comment the why, not the what. If you find yourself commenting what the code does, the code probably needs rewriting. If you find yourself explaining why it does it that way, a comment is the right tool.
Abstraction โ The Premature Kind Hurts More Than Duplication
The DRY principle โ Don't Repeat Yourself โ is the most over-applied principle in software. The first time a junior developer sees similar code in two places, they extract a shared function. The second time it appears, they make the function more generic with parameters. By the fourth time, there's an abstract base class with configuration options that nobody understands.
Premature abstraction creates coupling. Two pieces of code that happen to look similar today get merged into one. Later, their requirements diverge. Now you're adding flags and conditionals to handle the differences, and the "shared" function is harder to understand than the duplication was.
// Premature abstraction: one function trying to serve two masters
function formatEntity(entity: User | Product, options: {
showAvatar?: boolean;
showPrice?: boolean;
includeDescription?: boolean;
linkTarget?: string;
imageSize?: 'sm' | 'md' | 'lg';
}) {
// 50 lines of conditionals checking which entity type and which options
}
// Better: two focused functions
function formatUserCard(user: User) {
return `${user.avatar} ${user.name} โ ${user.role}`;
}
function formatProductListing(product: Product) {
return `${product.image} ${product.name} โ $${product.price}`;
}
The "Rule of Three" helps: don't abstract until you've seen the pattern three times. Two occurrences might be coincidence. Three suggests a genuine shared concept. And even then, only abstract if the shared parts are stable and the differences are clean.
As far as I can tell, duplication is the cost of independent evolution. When two similar pieces of code are duplicated, they can change independently without affecting each other. When they're abstracted, every change to one use case risks breaking the other. Choose your tradeoff based on how likely the code is to diverge.
Error Handling as a First-Class Concern
Clean code isn't just about the happy path. How you handle errors is often more important than how you handle success, because error paths are where users get stuck and where data corruption happens.
The pattern I've settled on after years of debugging production issues:
// Fail early and specifically
async function getUser(userId: string): Promise<User> {
if (!userId) {
throw new ValidationError('userId is required');
}
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
if (!user) {
throw new NotFoundError(`User ${userId} not found`);
}
return user;
}
Specific error types matter. throw new Error('something went wrong') is useless to the caller. throw new NotFoundError(...) lets the caller handle "not found" differently from "database connection failed" differently from "validation error." Custom error classes take five minutes to write and save hours of debugging:
class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number,
) {
super(message);
this.name = this.constructor.name;
}
}
class NotFoundError extends AppError {
constructor(message: string) {
super(message, 'NOT_FOUND', 404);
}
}
class ValidationError extends AppError {
constructor(message: string) {
super(message, 'VALIDATION_ERROR', 400);
}
}
The other mistake I used to make: swallowing errors silently. A try/catch with an empty catch block. The error disappears. The program continues in an invalid state. Days later, someone notices data is wrong and the root cause is invisible because the error was caught and ignored.
If you catch an error, do something meaningful with it. Log it. Return a failure result. Rethrow it. An empty catch block is almost never correct.
Consistent Formatting Is Non-Negotiable (but the Specific Format Doesn't Matter Much)
Tabs vs spaces. Semicolons or not. Trailing commas. Where to put the opening brace. People have strong opinions about all of these and none of them matter as much as consistency.
A codebase where everyone uses the same formatter is readable. A codebase where half the files use one style and half use another is jarring, regardless of which style is "better."
Use Prettier. Or use Biome. Or use whatever formatter your language's ecosystem has settled on. Configure it once, add it to your pre-commit hooks, and never discuss formatting in a code review again. The time saved on formatting arguments alone justifies the setup.
// .prettierrc โ set once, forget forever
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
The specific choices here are unimportant. What's important is that everyone's editor produces the same output. If I had a dollar for every PR comment about semicolon placement before we set up Prettier, I could fund an entire code quality conference.
When To Refactor (and When To Leave It Alone)
Refactoring everything constantly is not pragmatic. Refactoring nothing is how you get legacy code. The middle ground I've found useful:
Refactor when you're already changing the code. If you're adding a feature to a function and the function is messy, clean it up as part of the feature work. You're already reading and understanding the code. The marginal cost of improving it while you're in there is low.
Don't refactor code that works and isn't being modified. That ugly function from 2019 that nobody touches? Leave it. It's tested by time. The risk of introducing bugs during a refactor outweighs the aesthetic benefit of cleaner code that nobody reads.
Refactor when tests give you confidence. Without tests, refactoring is risky. With tests, you refactor, run the tests, and know immediately if you broke something. This is one of the strongest arguments for good test coverage โ not to catch bugs in new code, but to make existing code safe to improve.
// The boy scout rule: leave code better than you found it
// Before your change (existing code)
function getData(t, f) {
let r = [];
for (let i = 0; i < t.length; i++) {
if (f(t[i])) r.push(t[i]);
}
return r;
}
// After your change (you needed to modify this function anyway)
function filterItems<T>(items: T[], predicate: (item: T) => boolean): T[] {
return items.filter(predicate);
}
You were going to change this function anyway to add a new filter condition. While you're in there, rename the variables, add the type parameter, and replace the manual loop with the built-in filter. The cost is an extra five minutes. The benefit compounds every time someone reads this code in the future.
The Practical Checklist
After years of overthinking this, here's what I actually check when reviewing my own code or someone else's:
- Can I understand what this function does from its name alone? If not, rename it or break it up.
- If I were debugging this at 2 AM, could I follow the flow? If not, simplify the control flow or add comments explaining the non-obvious parts.
- Are errors handled or explicitly propagated? Silent error swallowing is a bug waiting to happen.
- Is there duplication that represents the same concept? Abstract it. Is there duplication that happens to look similar but represents different things? Leave it.
- Could a new team member modify this without breaking it? If the code is so clever that only the author understands it, it's not clean โ it's a liability.
That last point is the most important. Clean code isn't code that impresses other developers. It's code that the next person can safely modify. Sometimes that means short functions. Sometimes that means longer functions with clear flow. Sometimes that means comments. Sometimes that means no abstraction at all. The goal is always the same: reduce the cost of future changes.
The best code I've written, from what I've seen, isn't the most elegant. It's the code that someone else modified six months later without asking me any questions. That's what clean code actually looks like in practice.
Keep Reading
- Design Patterns in JavaScript โ The Ones That Actually Show Up in Real Code โ Clean code principles provide the foundation; design patterns give you reusable structures to apply them.
- Frontend Testing โ What to Actually Test and What's a Waste of Time โ Tests are what make clean code safe to refactor without fear of silent breakage.
Further Resources
- Refactoring.guru โ Visual explanations of refactoring techniques and design patterns, with code examples in multiple languages.
- Clean Code by Robert C. Martin (book summary) โ The original book that started the clean code movement, covering naming, functions, comments, and error handling principles.
- Google Engineering Practices: Code Review Guide โ Google's public guide to code review, covering what to look for and how to write code that passes review efficiently.
Written by
Anurag Sinha
Full-stack developer specializing in React, Next.js, cloud infrastructure, and AI. Writing about web development, DevOps, and the tools I actually use in production.
Stay Updated
New articles and tutorials sent to your inbox. No spam, no fluff, unsubscribe whenever.
I send one email per week, max. Usually less.
Comments
Loading comments...
Related Articles

Design Patterns in JavaScript โ The Ones That Actually Show Up in Real Code
Forget the Gang of Four textbook. These are the patterns I see in production JavaScript and TypeScript codebases every week โ observer, factory, strategy, and the ones nobody names but everyone uses.

Contributing to Open Source โ From First-PR Anxiety to Merged Code
How I got past the fear of my first pull request, found projects worth contributing to, and learned to read unfamiliar codebases without drowning.

SQLite โ The Most Underrated Database in Your Toolbox
Why I stopped reaching for Postgres by default and started shipping production apps with SQLite. WAL mode, embedded analytics, and when it genuinely beats the big databases.