Clean Code: The Well-Intentioned Anti-Pattern
Disclaimer: What follows is a personal perspective based on my experiences and observations in software development. Like any opinions about software practices, these should be taken with a grain of salt and evaluated against your own context, team dynamics, and project requirements. There's rarely a one-size-fits-all solution in software development, and even the critiques presented here might not apply to your specific situation.
In the pantheon of programming books, few have achieved the near-religious status of Robert C. Martin's "Clean Code." Its principles have been passed down from senior developer to junior developer like sacred commandments.
But here's my controversial take: many of Clean Code's core tenets are not just outdated—they're actively harmful to modern software development.
The Problem with Premature Abstraction
My biggest gripe with Clean Code lies in its almost zealous advocacy for abstraction and "proper" object-oriented design.
The book pushes developers toward a world where every piece of functionality must be neatly encapsulated in its own class, every behavior must be abstracted behind an interface, and every operation should be broken down into its smallest possible components.
While this sounds great in theory, in practice it often leads to codebases that are drowning in unnecessary abstractions—where a simple task requires diving through layers of classes, interfaces, and design patterns.
I've seen too many projects where a straightforward 30-line function gets transformed into a complex web of 5-6 classes, each with its own single responsibility, yet collectively making the code harder to understand, maintain, and debug. This isn't clean code; it's complexity theater.
Consider a typical e-commerce checkout process. Would you rather have:
def process_checkout(order):
"""
Process an e-commerce checkout with validation, payment, and post-purchase actions.
All the behavior and logic flow is visible in one place.
"""
# Validate inventory first to avoid payment issues
inventory_status = validate_inventory(order.items)
if not inventory_status.is_valid:
return CheckoutResult(
success=False,
error=f"Insufficient inventory for {inventory_status.failed_item}"
)
# Calculate all amounts
try:
total = calculate_total(order.items)
tax = tax_calculator.apply_tax(total, order.shipping_address)
shipping = shipping_calculator.calculate_rate(
items=order.items,
method=order.shipping_method,
address=order.shipping_address
)
final_amount = total + tax + shipping
except PricingError as e:
return CheckoutResult(
success=False,
error=f"Error calculating prices: {str(e)}"
)
# Validate order minimum and restrictions
if total < settings.MINIMUM_ORDER_AMOUNT:
return CheckoutResult(
success=False,
error=f"Order minimum is ${settings.MINIMUM_ORDER_AMOUNT}"
)
if not shipping_validator.is_address_serviceable(order.shipping_address):
return CheckoutResult(
success=False,
error="We don't ship to this address"
)
# Process payment
payment_result = payment_service.process_payment(
amount=final_amount,
payment_info=order.payment_info,
currency=order.currency
)
if not payment_result.success:
return CheckoutResult(
success=False,
error=f"Payment failed: {payment_result.error}"
)
# If we got here, payment succeeded. Update inventory and notify
try:
inventory_service.update_inventory(order.items)
order.status = OrderStatus.CONFIRMED
order.confirmation_number = generate_confirmation_number()
order.save()
# Send notifications
notification_service.send_confirmation_email(
to_email=order.email,
order_number=order.confirmation_number
)
if order.total > 1000:
notification_service.notify_high_value_purchase(order)
return CheckoutResult(
success=True,
order_number=order.confirmation_number,
estimated_delivery=shipping_calculator.estimate_delivery(order)
)
except Exception as e:
# Something went wrong after payment - need customer service involvement
notification_service.notify_failed_checkout(order, str(e))
return CheckoutResult(
success=False,
error="Order was processed but failed to complete. Our team will contact you.",
order_number=order.confirmation_number
)
Or:
class OrderProcessor:
def __init__(self, inventory_validator, amount_calculator,
payment_processor, post_processor):
self.inventory_validator = inventory_validator
self.amount_calculator = amount_calculator
self.payment_processor = payment_processor
self.post_processor = post_processor
def process_checkout(self, order):
"""Main checkout orchestrator following SRP and clean code principles"""
try:
if not self._validate_pre_checkout(order):
return self._failed_result("Pre-checkout validation failed")
if not self._process_payment_flow(order):
return self._failed_result("Payment processing failed")
return self._handle_post_payment(order)
except Exception as e:
return self._handle_checkout_error(e)
def _validate_pre_checkout(self, order):
return (self._check_inventory_availability(order) and
self._validate_order_constraints(order) and
self._verify_shipping_details(order))
def _check_inventory_availability(self, order):
return self.inventory_validator.validate_stock_levels(order.items)
def _validate_order_constraints(self, order):
return (self._validate_minimum_amount(order) and
self._validate_shipping_restrictions(order))
def _validate_minimum_amount(self, order):
return order.total >= self._get_minimum_order_amount()
def _get_minimum_order_amount(self):
return settings.MINIMUM_ORDER_AMOUNT
def _validate_shipping_restrictions(self, order):
return ShippingValidator().is_address_serviceable(order.shipping_address)
def _verify_shipping_details(self, order):
return self.shipping_validator.verify_shipping_details(
order.shipping_address,
order.shipping_method
)
def _process_payment_flow(self, order):
return (self._calculate_amounts(order) and
self._validate_calculated_amounts(order) and
self._process_payment(order))
def _calculate_amounts(self, order):
try:
self._set_order_subtotal(order)
self._set_order_tax(order)
self._set_shipping_cost(order)
return True
except PricingError:
return False
def _set_order_subtotal(self, order):
order.subtotal = self.amount_calculator.calculate_subtotal(order.items)
def _set_order_tax(self, order):
order.tax = self.amount_calculator.calculate_tax(
order.subtotal,
order.shipping_address
)
def _set_shipping_cost(self, order):
order.shipping = self.amount_calculator.calculate_shipping(
order.items,
order.shipping_method,
order.shipping_address
)
def _validate_calculated_amounts(self, order):
validator = OrderAmountValidator(order)
return validator.validate()
def _process_payment(self, order):
return self.payment_processor.process(
amount=self._get_total_amount(order),
payment_info=order.payment_info,
currency=order.currency
)
def _get_total_amount(self, order):
return order.subtotal + order.tax + order.shipping
def _handle_post_payment(self, order):
if not self._perform_post_payment_tasks(order):
return self._failed_result("Post-payment processing failed")
return self._create_success_result(order)
def _perform_post_payment_tasks(self, order):
return (self._update_inventory(order) and
self._update_order_status(order) and
self._send_notifications(order))
def _update_inventory(self, order):
return self.inventory_service.update_inventory(order.items)
def _update_order_status(self, order):
order.status = OrderStatus.CONFIRMED
order.confirmation_number = self._generate_confirmation_number()
return order.save()
def _generate_confirmation_number(self):
return f"ORD-{uuid.uuid4().hex[:8].upper()}"
def _send_notifications(self, order):
notification_sender = NotificationSender()
if not notification_sender.send_confirmation_email(order):
return False
if self._is_high_value_order(order):
notification_sender.notify_high_value_purchase(order)
return True
def _is_high_value_order(self, order):
return order.total > settings.HIGH_VALUE_THRESHOLD
def _create_success_result(self, order):
return CheckoutResult(
success=True,
order_number=order.confirmation_number,
estimated_delivery=self._calculate_estimated_delivery(order)
)
def _calculate_estimated_delivery(self, order):
return ShippingCalculator().estimate_delivery(order)
def _failed_result(self, error_message):
return CheckoutResult(success=False, error=error_message)
def _handle_checkout_error(self, error):
notification_service.notify_failed_checkout(str(error))
return self._failed_result(
"An unexpected error occurred. Our team will contact you."
)
The first version might be longer as a single function, but it tells a clear story. You can see the entire checkout flow in one place, understand the sequence of operations, and debug issues without jumping between files. Notice how in the second version, just to understand what happens after payment, you need to jump through _handle_post_payment
, _perform_post_payment_tasks
, and three other methods. This scattering of related behavior makes the code significantly harder to understand and maintain.
Why I Don't Like DRY
The DRY principle, while valuable, has been taken to extremes. In our quest to eliminate any duplicate code, we often create abstractions that are:
- More complex than the duplication they eliminate
- Coupled in ways that make future changes harder
- So generic they become hard to understand
I've seen countless projects where developers, in their fervent pursuit of DRY, create elaborate "utility" classes and "shared" components that try to handle every possible edge case. What starts as two similar pieces of code ends up becoming a Byzantine framework of configuration options, factory methods, and inheritance hierarchies.
The result? Code that's technically "DRY" but is so abstract and parameterized that it takes twice as long to understand and three times as long to modify.
Sometimes, a little bit of controlled duplication is better than the wrong abstraction.
As Dan Abramov once said, "Duplication is far cheaper than the wrong abstraction." This is particularly true for core programming logic where clarity and directness should prevail.
Save your DRY efforts for where they matter most: business logic and domain-specific rules. If you find yourself writing the same complex business calculation or workflow in multiple places, by all means, abstract it. But if you're dealing with basic programming constructs, sometimes the clearest solution is to just write it twice.
Why Small Functions Aren't Always Better
The book's insistence that functions should be small—ideally 5-10 lines—ignores a crucial aspect of cognitive load. Breaking down a 30-line function into six 5-line functions doesn't automatically make the code more maintainable. In fact, it often:
- Forces readers to jump between multiple functions to understand the flow
- Creates unnecessary abstraction boundaries
- Makes debugging more difficult
- Adds cognitive overhead of naming and organizing these mini-functions
Consider this simple example of calculating a user's subscription price:
# "Clean" version with small functions
def calculate_subscription_price(user, plan, coupon):
base_price = get_base_price(plan)
user_discount = calculate_user_discount(user)
coupon_discount = calculate_coupon_discount(coupon)
business_price = apply_business_rules(user, base_price)
return apply_discounts(business_price, user_discount, coupon_discount)
def get_base_price(plan):
return PLAN_PRICES[plan]
def calculate_user_discount(user):
if user.subscription_months > 12:
return 0.1
return 0
def calculate_coupon_discount(coupon):
if coupon and not coupon.expired:
return coupon.discount_value
return 0
def apply_business_rules(user, price):
if user.is_enterprise and price > 100:
return price * 0.85
return price
def apply_discounts(price, user_discount, coupon_discount):
return price * (1 - user_discount) * (1 - coupon_discount)
# Versus the more straightforward version
def calculate_subscription_price(user, plan, coupon):
price = PLAN_PRICES[plan]
# Apply enterprise discount for large purchases
if user.is_enterprise and price > 100:
price *= 0.85
# Apply loyalty discount for long-term users
if user.subscription_months > 12:
price *= 0.9
# Apply coupon if valid
if coupon and not coupon.expired:
price *= (1 - coupon.discount_value)
return price
What's even more important is the concept of locality of behavior. When you split a coherent piece of logic across multiple tiny functions, you're forcing developers to mentally reconstruct the entire flow by jumping between different parts of the codebase.
This completely breaks locality of behavior – the idea that related code should stay together. I've found that teams are significantly more productive when related behaviors are kept in close proximity, even if it means having slightly longer functions.
The Modern Reality
Today's development environment is radically different from when Clean Code was written. In a world where software eats the world and startups need to move fast or die:
- Modern IDEs make navigating larger code blocks easier with powerful search, refactoring, and navigation tools
- Code review tools handle larger chunks of code better, with better diff views and inline commenting
- Runtime performance matters more than ever, with users expecting lightning-fast responses
- Teams are more distributed, making complex abstractions harder to collaborate on
The reality of modern software development is that productivity and time-to-market are paramount. Companies can't afford to spend weeks perfecting abstractions that might never be needed. When a startup needs to ship a feature to close a crucial deal, or when a team needs to quickly patch a production issue, the last thing they need is to navigate through 15 layers of abstraction to make a simple change.
Today's engineering teams need to:
- Ship features fast to validate business hypotheses
- Iterate quickly based on user feedback
- Scale systems rapidly when they find product-market fit
- Maintain performance under increasing load
- Onboard new team members efficiently
This doesn't mean we should write spaghetti code or ignore good practices. But it does mean we need to be pragmatic.
A straightforward, slightly longer function that new team members can understand in minutes is often better than a "clean" architecture that takes days to comprehend. The cost of over-engineering isn't just in the initial development time—it's in every future interaction with the code, every new hire's onboarding, every emergency debug session at 3 AM.
A Better Approach
Instead of blindly following Clean Code principles, here's what I've found works better in real-world software development:
Don't Be Afraid to Repeat Yourself When Necessary
The fear of duplication shouldn't drive you to create complex abstractions. Sometimes, having two similar but separate pieces of code is more maintainable than a single, over-generalized abstraction. Remember: code is more often read than written, and duplicated code that's easy to understand is better than a "DRY" abstraction that nobody can decipher.
Avoid Premature Abstractions Like the Plague
Wait until you have at least three concrete use cases before creating an abstraction. Abstract patterns should emerge from real requirements, not anticipated ones. I've seen too many codebases crippled by "flexible" abstractions built for scenarios that never materialized. As the saying goes: "Make it work, make it right, make it fast" – but never make it abstract before you need to.
Prioritize Locality of Behavior
Related code should stay together. Period. When you spread related functionality across multiple files and classes in the name of "separation of concerns," you're actually creating a separation of related things. This makes it harder for developers to understand how the system works, as they need to mentally reconstruct the relationships between disparate pieces of code.
Write Code for Future Maintainers
Always think about the poor soul who will maintain your code six months from now (spoiler alert: it might be you). Ask yourself:
- Will someone new to the team understand this code without needing to jump through five different files?
- Is the flow of execution clear and obvious?
- Would you feel confident making changes to this code at 3 AM during an outage?
- Are you building a cathedral when all you need is a shed?
Remember: the best code isn't the one that follows all the "clean code" rules – it's the one that helps your team ship features reliably and maintain them efficiently over time. Sometimes that means breaking a few rules in the name of clarity and practicality.
Conclusion
Clean Code was written in a different era, for different challenges. While some of its principles remain valuable, blindly following its rules can lead to over-engineered, hard-to-maintain codebases.
Sometimes, the cleanest code is the code that's simply straightforward and obvious, even if it breaks a few "clean code" rules along the way.
At the end of the day, everything I've written here is just my opinion based on years of dealing with both over-engineered nightmares and beautifully straightforward codebases. If you disagree with my take on this, that's completely fine – I honestly don't care. We're all probably going to be replaced by AI pair programmers soon anyway, and they'll probably write better code than both the "clean" and "dirty" versions. They might even write this entire article better than I did.
In the meantime, while we still have jobs, let's focus on writing code that actually helps our teams ship products, rather than code that would get an A+ in a software architecture class.