All Posts

Simplifying Code with the Single Responsibility Principle

A class should have one, and only one, reason to change. We explain the 'S' in SOLID with a simpl...

Abstract AlgorithmsAbstract Algorithms
ยทยท11 min read

AI-assisted content.

TLDR

TLDR: The Single Responsibility Principle says a class should have only one reason to change. If a change in DB schema AND a change in email format both require you to edit the same class, that class has two responsibilities โ€” and needs to be split.


๐Ÿ“– The Filing Cabinet That Also Sends Emails

Imagine a filing clerk whose job is:

  1. Store documents (filing cabinet logic).
  2. Send notification emails whenever a document is filed.
  3. Log activity to an audit trail.

One person doing three jobs โ€” fine until they're on vacation and you only need the email changed. You have to find and understand the whole multi-responsibility class just to change one notification template.

SRP says: each class has one job. One reason to put it in "maintenance mode."


๐Ÿ” The Classic Violation: User Manager Who Does Everything

// โŒ SRP violation โ€” two very different reasons to change this class
class UserManager {
    public void saveUser(User user) {
        // 1. save to DB
        db.execute("INSERT INTO users VALUES (?)", user.data());
        // 2. send a welcome email
        emailService.send(user.email, "Welcome to our platform!", WELCOME_TEMPLATE);
        // 3. write audit log
        logger.info("User created: " + user.getId());
    }
}

Reasons this class must change:

  • The database schema changes โ†’ modify DB logic.
  • The welcome email template changes โ†’ modify email logic.
  • The audit log format changes โ†’ modify logging logic.

Three reasons to change = three responsibilities.

๐Ÿ“Š SRP Violation: UserManager With Three Reasons to Change

classDiagram
    class UserManager {
        +saveUser()
        +sendWelcomeEmail()
        +logAuditTrail()
    }
    note for UserManager "Violates SRP: DB, email, and logging in one class"

This class diagram exposes the SRP violation at a glance: UserManager holds three unrelated operations โ€” saveUser (database concern), sendWelcomeEmail (notification concern), and logAuditTrail (observability concern). Each belongs to a different team's domain and a different reason to change. The takeaway is that whenever a class diagram shows methods from clearly different business domains in one box, the class has too many responsibilities.


โš™๏ธ The SRP Fix: One Class, One Job

// โœ… Split into single-responsibility classes
class UserRepository {
    public void save(User user) {
        db.execute("INSERT INTO users VALUES (?)", user.data());
    }
}

class WelcomeEmailService {
    public void sendWelcome(User user) {
        emailService.send(user.email, "Welcome!", WELCOME_TEMPLATE);
    }
}

class UserAuditLogger {
    public void logCreated(User user) {
        logger.info("User created: " + user.getId());
    }
}

// Orchestrator โ€” knows when to call each, but not how they work
class UserRegistrationService {
    private final UserRepository repo;
    private final WelcomeEmailService emailSvc;
    private final UserAuditLogger audit;

    public void register(User user) {
        repo.save(user);
        emailSvc.sendWelcome(user);
        audit.logCreated(user);
    }
}

Now each class has exactly one reason to change. UserRegistrationService orchestrates the flow but owns none of the individual mechanics.

Notice the key benefit beyond cleanliness: independent deployability of understanding. A new developer joining the team can read WelcomeEmailService in thirty seconds and fully understand what it does and what could cause it to change. With the original UserManager, understanding the email logic required reading through unrelated database and logging code first. At scale โ€” with hundreds of classes, dozens of developers โ€” that cognitive overhead compounds into real velocity loss.

SRP also makes testing dramatically easier. Each focused class can be unit-tested with a single mock at most. The original UserManager required mocking a database, an email service, and a logger for every test โ€” even when you only cared about email behavior.

๐Ÿ“Š SRP Correct: Each Class Has Its Own Single Reason to Change

classDiagram
    class UserRegistrationService {
        +register()
    }
    class UserRepository {
        +save()
    }
    class WelcomeEmailService {
        +sendWelcome()
    }
    class UserAuditLogger {
        +logCreated()
    }
    UserRegistrationService --> UserRepository
    UserRegistrationService --> WelcomeEmailService
    UserRegistrationService --> UserAuditLogger

This class diagram shows the SRP-correct design: UserRegistrationService is now a thin orchestrator with arrows pointing to three single-purpose collaborators. Each leaf class (UserRepository, WelcomeEmailService, UserAuditLogger) has exactly one reason to change โ€” a database schema change only touches UserRepository, an email template update only touches WelcomeEmailService. Notice that the arrows represent dependency, not inheritance โ€” this is the shape of a clean, testable design.


๐Ÿง  Deep Dive: Detecting SRP Violations

Common signals:

SignalExample
Class name contains "And"FileReaderAndParser, UserManagerAndNotifier
More than ~200 lines in a single classLogic has grown without boundaries
Unit test needs many unrelated mocksnew UserManager(mockDB, mockEmailService, mockLogger, mockMetrics, ...)
Change in one feature breaks a test for anotherUpdating DB logic fails email tests
Merge conflicts between teammates on the same classTwo teams editing the same file for unrelated features

โš–๏ธ Trade-offs & Failure Modes: SRP vs. Cohesion

SRP is sometimes misunderstood as "one method per class." That's wrong.

Cohesion is the right mental model: group methods that change together and depend on the same data. A User class can have getFullName(), getEmail(), and isActive() โ€” all relate to the same entity and would change for the same reasons.

Too granular (over-SRP)Appropriately SRPToo coarse
UserFirstNameGetter, UserLastNameGetterUser (all core user properties)UserManagerNotifierLogger
EmailValidator, EmailLengthCheckerEmailValidator (all validation rules)UserEmailAndPermissionClass

Rule of thumb: Ask "If the business rule changes for X, what code must change?" Group that code together.

๐Ÿ“Š SRP Decision Flow: Does This Class Have One Reason to Change?

flowchart TD
    A[Review Class] --> B{One reason to change?}
    B -- Yes --> C[Follows SRP]
    C --> D[Keep as is]
    B -- No --> E[Identify Responsibilities]
    E --> F[Extract Each Responsibility]
    F --> G[Create Focused Classes]
    G --> H[Inject Dependencies]
    H --> I[Now follows SRP]

This flowchart gives you a repeatable decision process for any class under review. Start at "Review Class" and ask a single binary question: does it have one reason to change? If yes, nothing needs to happen. If no, the flow guides you through identifying each responsibility, extracting it into a focused class, and injecting dependencies โ€” the result is a set of classes each pointing to a single reason to change. The key insight is that SRP refactoring always ends with the same structural pattern: one orchestrator and multiple focused collaborators.


๐Ÿ“Š SRP Responsibility Flow

When a request hits UserRegistrationService.register(), responsibility flows cleanly to dedicated single-purpose classes โ€” each is an isolated change axis.

flowchart LR
    A[register(user)] --> B[UserRepository.save]
    A --> C[WelcomeEmailService.sendWelcome]
    A --> D[UserAuditLogger.logCreated]
    B --> E[(Database)]
    C --> F[Email Provider]
    D --> G[Audit Log]

If the database schema changes, only UserRepository is modified. If the email template changes, only WelcomeEmailService is touched. The orchestrator UserRegistrationService never changes for infrastructure reasons โ€” only if the registration workflow itself changes.


๐ŸŒ Real-World Application: SRP at Scale

SRP applies at every scale โ€” class, service, and infrastructure.

LayerSRP ViolationSRP-Compliant
ClassUserManager (save + email + audit)UserRepository, EmailService, AuditLogger
REST controllerOne endpoint handles auth AND business logicSeparate auth middleware, business handler
MicroserviceOrderService manages checkout, inventory, and shippingSeparate checkout, inventory, shipping services
Lambda functionOne function parses API events and writes to DBParser function โ†’ writer function
Databaseusers table stores both profile data and activity logsSeparate users and user_activity tables

Where to apply SRP first: Start at the class level. Pain signals: unit tests with 5+ mocks in setUp, class names with "And" or "Manager", files frequently edited by two different teams for unrelated features.

At the microservice level, SRP manifests as bounded contexts โ€” each service owns one business capability. Conway's Law predicts teams build systems that mirror their communication structure, so SRP at the organizational level means SRP in the architecture.


๐Ÿงช Hands-On: Identify and Refactor SRP Violations

Here is a class that violates SRP. Identify the responsibilities, then split them.

// ๐Ÿ›‘ How many responsibilities does this class have?
class OrderProcessor {
    public void processOrder(Order order) {
        // Responsibility 1: Validate
        if (order.getItems().isEmpty()) {
            throw new IllegalArgumentException("Order has no items");
        }
        // Responsibility 2: Charge
        stripeClient.charge(order.getCustomerId(), order.getTotal());
        // Responsibility 3: Update inventory
        for (Item item : order.getItems()) {
            inventoryDb.decrementStock(item.getSku(), item.getQuantity());
        }
        // Responsibility 4: Notify
        emailService.send(order.getEmail(), "Order confirmed", buildBody(order));
        // Responsibility 5: Audit
        logger.info("Order processed: " + order.getId());
    }
}

Five responsibilities โ†’ five focused classes:

Original responsibilityNew class
Validate orderOrderValidator
Charge customerPaymentService
Update inventoryInventoryService
Send confirmationOrderNotificationService
Write auditOrderAuditLogger

Orchestrator:

class OrderProcessingService {
    public void processOrder(Order order) {
        validator.validate(order);
        paymentService.charge(order);
        inventoryService.decrementStock(order);
        notificationService.sendConfirmation(order);
        auditLogger.logProcessed(order);
    }
}

OrderProcessingService has one responsibility: orchestrating the order workflow. Each collaborator has one responsibility: doing its specific job.


Decision Guide

SituationRecommended Action
Class name contains "And" or "Manager"Split into focused single-purpose classes
Unit test setUp requires 5+ unrelated mocksRefactor the class under test to reduce responsibilities
Two teammates edit the same file for unrelated featuresSeparate those responsibilities into distinct classes
Change in one area breaks tests for anotherIsolate the two concerns
Entity class has 3โ€“4 cohesive methodsKeep as-is โ€” cohesion is not an SRP violation

๐Ÿ› ๏ธ Spring Framework: How Dependency Injection Makes SRP Unavoidable

Spring Framework is a lightweight Java application framework built around dependency injection (DI) and inversion of control. It is the dominant enterprise Java framework, used across millions of Spring Boot production systems worldwide.

When you split a bloated UserManager into UserRepository, WelcomeEmailService, and UserAuditLogger, Spring's DI container wires them together automatically at startup. Each @Service class declares its single responsibility by name, and constructor injection makes every collaborator explicit โ€” no hidden new calls, no tangled constructors.

// โœ… Spring DI makes each responsibility a named, independently testable bean
@Service
@RequiredArgsConstructor          // Lombok generates constructor injection
public class UserRepository {
    private final JdbcTemplate jdbc;

    public void save(User user) {
        jdbc.update("INSERT INTO users (id, email) VALUES (?, ?)",
                user.getId(), user.getEmail());
    }
}

@Service
@RequiredArgsConstructor
public class WelcomeEmailService {
    private final JavaMailSender mailer;

    public void sendWelcome(User user) {
        SimpleMailMessage msg = new SimpleMailMessage();
        msg.setTo(user.getEmail());
        msg.setSubject("Welcome to Abstract Algorithms!");
        mailer.send(msg);
    }
}

@Service
@RequiredArgsConstructor
public class UserRegistrationService {      // orchestrator โ€” one responsibility
    private final UserRepository      repo;
    private final WelcomeEmailService emailSvc;
    private final UserAuditLogger     audit;

    public void register(User user) {
        repo.save(user);
        emailSvc.sendWelcome(user);
        audit.logCreated(user);
    }
}

Unit-testing WelcomeEmailService now requires mocking only JavaMailSender. The test setUp is five lines, not fifty. Spring's @MockBean replaces any collaborator in integration tests without touching the others โ€” the payoff of SRP is felt directly at the test layer, where mocking pressure drops to zero.

For a full deep-dive on Spring Framework dependency injection and bean lifecycle management, a dedicated follow-up post is planned.


๐Ÿ“š Lessons Learned From SRP in Practice

  • The "And" test always works. If you cannot name a class without "And" or "Manager", it has multiple responsibilities. UserManagerAndNotifier should be two classes.
  • Merge conflicts signal SRP violations. When two teammates routinely edit the same file for unrelated features, that file is a shared liability โ€” split it.
  • SRP โ‰  small classes. A 300-line BillingCalculator can be SRP-compliant if all its methods change for the same reason. A 40-line class can violate SRP if it mixes two unrelated concerns.
  • Orchestrators are not violations. UserRegistrationService calling UserRepository, EmailService, and AuditLogger is an orchestrator with one responsibility: coordinating registration. It does not implement any of the individual operations.
  • Refactor when it hurts, not preemptively. Wait until you feel real pain (slow tests, confusing code, constant merge conflicts) before splitting. Premature SRP splits add complexity without payoff.
  • SRP and DIP work together. Once you split a class into focused pieces, injecting them as interfaces (DIP) makes the system testable and flexible. SRP defines the boundaries; DIP keeps them loosely coupled.

๐Ÿ“Œ TLDR: Summary & Key Takeaways

  • A class should have one reason to change โ€” one axis of responsibility.
  • The "And" smell in class names, oversized test setUp, and frequent merge conflicts all signal SRP violations.
  • Split responsibilities into focused classes; use an orchestrator to compose them.
  • Don't over-SRP: cohesion means grouping code that changes together โ€” not one method per class.
  • Applied consistently, SRP reduces cognitive overhead, speeds up onboarding, and makes every unit test narrower and faster to write.


Share
Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms