Marcio Cunha

Duplicate code? How to apply SOLID principles in practice

Learn how to refactor complex procedural code snippets into clean, testable, and decoupled object-oriented structures using SOLID principles.

Marcio Cunha•4 min
Also available in:PortuguêsEspañol
Summary
  • Code duplication in procedural routines creates structural rigidity that hinders future changes and multiplies points of failure.
  • The single responsibility principle isolates distinct behaviors to ensure that each class changes for only one reason.
  • Injecting dependencies directly into structures decouples modules and enables the creation of isolated unit tests.
  • Openness for extension and closure for modification allow adding features without rewriting existing code.
  • Gradual refactoring transforms linear, complex blocks into cohesive, readable, and easy-to-maintain classes.

The silent danger of repeated procedural code

Every programmer has encountered that giant file where dozens of lines of code repeat with minor variations. In traditional procedural programming, the focus is on a linear sequence of instructions executed step by step. However, when a business requirement changes, this approach takes its toll, demanding modifications in multiple places and increasing the risk of bugs. To understand how to mitigate this issue, system maintainability directly depends on structural clarity, a challenge similar to organizing complex bases or even structuring microservices when migrating a giant monolith painlessly into modular architectures.

In practice, this means that the more procedural logic we accumulate inside a single function, the harder it becomes to test the system. A common mistake is mixing calculation rules, database access, and notification delivery into a single monolithic block. When code grows without a clear division of roles, software loses flexibility, and the team spends more time fixing collateral failures than building new features.

Identifying symptoms of excessive coupling

Excessive coupling occurs when different parts of software heavily depend on each other in a rigid manner. If we alter a database table structure and that breaks display logic on the screen, we have a strong indicator that architectural boundaries have been breached. This scenario usually comes accompanied by long functions full of nested conditionals trying to guess all execution possibilities in a single flow.

To illustrate this scenario, imagine a procedural routine that processes payments and triggers confirmation emails in the same block of code. If we need to swap the email provider or add a new discount rule, we have to touch a highly sensitive line of code that already works. It is precisely at this point that the intelligent application of object-oriented concepts becomes indispensable to decouple responsibilities.

Applying single responsibility to separate tasks

The first step to organize things is applying the concept that each part of the system should have only one reason to change. In practice, separate pure business logic from infrastructure rules, such as network connections or data persistence. Instead of one giant function that does everything, we create specialized classes where each executes a single specific task to perfection.

Below is a classic example of initial procedural code filled with mixed responsibilities:

// Coupled and hard-to-test procedural code
function processOrder($order) {
    // Validation
    if (empty($order['items'])) {
        throw new Exception('Empty order');
    }
    
    // Price calculation
    $total = 0;
    foreach ($order['items'] as $item) {
        $total += $item['price'];
    }
    
    // Save to database
    $db = new PDO('mysql:host=localhost;dbname=store', 'user', 'pass');
    $stmt = $db->prepare('INSERT INTO orders (total) VALUES (?)');
    $stmt->execute([$total]);
    
    // Send email
    mail($order['email'], 'Confirmation', 'Your order has been processed.');
}

This block handles validation, calculation, persistence, and messaging all at once. Any change to the database format or email server requires altering this exact function, violating system cohesion.

Refactoring into cohesive and decoupled classes

To solve the previous problem, we separate responsibilities into small, well-defined classes. Thus, validation lives in its own component, price calculation in another, and persistence uses an interface that abstracts the database. This drastic separation reduces duplication and allows each class to be tested in complete isolation.

See how the refactored structure looks using object-oriented concepts and dependency injection:

// Cohesive and decoupled classes
class OrderValidator {
    public function validate(array $order): void {
        if (empty($order['items'])) {
            throw new Exception('Empty order');
        }
    }
}

class PriceCalculator {
    public function calculate(array $items): float {
        $total = 0;
        foreach ($items as $item) {
            $total += $item['price'];
        }
        return $total;
    }
}

class OrderProcessor {
    private $validator;
    private $calculator;

    public function __construct(OrderValidator $validator, PriceCalculator $calculator) {
        $this->validator = $validator;
        $this->calculator = $calculator;
    }

    public function execute(array $order): float {
        $this->validator->validate($order);
        return $this->calculator->calculate($order['items']);
    }
}

With this approach, if we need to change tax calculation rules or switch messaging libraries, the impact is strictly limited to the corresponding class. Software maintainability jumps to a whole new level.

Ensuring extensibility without modifying the core

Another fundamental gain when applying these principles is the ability to extend system behavior without needing to rewrite existing code. When we use interfaces to define communication contracts between modules, we can plug in new features simply by creating new classes that implement those same interfaces, keeping general application stability intact.

This flexibility is essential in dynamic environments where business rules change constantly. Instead of cluttering files with if/else conditionals to check which behavior to run, we delegate this choice to polymorphism, making code cleaner, more elegant, and resilient to unexpected changes.

Final considerations on code evolution

Refactoring old procedural code requires patience and discipline, but medium and long-term benefits vastly outweigh initial effort. By dividing large blocks of logic into cohesive object-oriented structures, the team gains speed and confidence to deliver new features without fear of breaking legacy systems. Adopting these practices transforms fragile codebases into robust, testable software ecosystems prepared for sustainable growth.