Software Architecture

Refactoring Monoliths with the Single Responsibility Principle

Legacy systems often suffer from bloated classes that handle too many concerns. This "God Object" anti-pattern makes code difficult to test, maintain, and extend. By applying the Single Responsibility Principle (SRP), we can break these monolithic services into smaller, focused units without rewriting the entire system. This guide provides a practical roadmap for implementing SRP in production environments.

Identifying Responsibility Violations

The first step is identifying where SRP is violated. A class has a single responsibility if there is only one reason for it to change. In legacy code, this often manifests as a class that handles database access, business logic, and user interface formatting simultaneously.

Look for these signs in your existing codebase:

  • Classes with excessive dependencies on other components.
  • Methods that perform multiple unrelated tasks (e.g., validating data and sending emails).
  • High cyclomatic complexity, indicating tangled logic.
  • Frequent merge conflicts in specific files during collaborative development.

Step 1: Extract Domain Logic

The safest way to start refactoring is by extracting pure business logic from infrastructure concerns. Consider a legacy `OrderProcessor` class that handles everything from calculating totals to updating the database.


// Legacy Violation: God Object
public class OrderProcessor {
    public void processOrder(Order order) {
        // 1. Business Logic
        double tax = order.getTotal() * 0.1;
        order.setTotal(order.getTotal() + tax);

        // 2. Infrastructure Concern
        databaseConnection.update("orders", order);

        // 3. Side Effect
        emailService.sendConfirmation(order.getCustomerEmail());
    }
}

This class changes for three reasons: tax rules change, database schemas change, or email providers change. To apply SRP, we separate the calculation logic into a dedicated service.

Step 2: Implement a Strategy Pattern for Pricing

By extracting the tax calculation, we create a `TaxCalculator` interface. This isolates the logic and allows us to swap implementations without touching the main order flow.


// New SRP-Compliant Structure
public interface TaxCalculator {
    double calculate(Order order);
}

public class StandardTaxCalculator implements TaxCalculator {
    public double calculate(Order order) {
        return order.getTotal() * 0.1;
    }
}

// Refactored Processor
public class OrderProcessor {
    private final TaxCalculator taxCalculator;
    private final OrderRepository repository;
    private final NotificationService notifier;

    public OrderProcessor(TaxCalculator taxCalculator, OrderRepository repo, NotificationService notifier) {
        this.taxCalculator = taxCalculator;
        this.repository = repo;
        this.notifier = notifier;
    }

    public void processOrder(Order order) {
        order.setTotal(order.getTotal() + taxCalculator.calculate(order));
        repository.save(order);
        notifier.sendConfirmation(order);
    }
}

Step 3: Separate Infrastructure Concerns

Next, we ensure that persistence and notification logic are delegated to interfaces. This decouples the core business process from specific technology choices. The `OrderProcessor` now only knows about the abstractions, adhering to the Dependency Inversion Principle, which supports SRP by keeping the class focused solely on orchestrating the workflow.

When testing this new implementation, you can mock the `TaxCalculator` and `NotificationService` easily. Previously, unit testing the legacy `OrderProcessor` required a database and email server setup, making tests slow and flaky.

Managing the Transition

Refactoring a large monolith is risky. Use the "Strangler Fig" pattern to mitigate this risk. Introduce the new, SRP-compliant components behind a feature flag. Gradually route traffic to the new implementation while the old code still exists. Once you are confident in the stability of the new modules, remove the legacy code.

Automated tests are non-negotiable during this process. Before touching any legacy code, write integration tests that cover the existing behavior. These tests serve as your safety net, ensuring that your refactoring changes structure but not functionality.

Conclusion

Applying the Single Responsibility Principle to legacy code is not about rewriting everything from scratch; it is about incremental improvement. By isolating business logic, separating infrastructure concerns, and using design patterns to manage dependencies, you can transform brittle monoliths into maintainable services. Start small, test thoroughly, and commit to the discipline of one reason for change per class. The result is code that is easier to understand, test, and extend for years to come.

Share: