João Cardoso

Clean Code: The Well-Intentioned Anti-Pattern

4 de novembro de 2024

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.

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.

The Problem with Premature Abstraction

The book's obsession with small functions, DRY (Don't Repeat Yourself) principle, and "clean" abstractions often leads developers down a path of complexity hell. We've all been there: turning a simple 20-line function into a baroque architecture of classes, interfaces, and dependency injections—all in the name of "cleanliness."

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:

1. More complex than the duplication they eliminate
2. Coupled in ways that make future changes harder
3. 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.

Let's talk about your project

Get in touch to ask questions or request our services.

By submitting this form, you agree to our Privacy Policy.