Single Responsibility Principle: One Class, One Job

SOLID Principles Series #1


A new security requirement arrives: passwords must be hashed with bcrypt instead of MD5. Simple enough. You open the User class to make the change and find yourself scrolling past email-sending logic, database queries, and input validation before you even get to the hashing method. You make the change, run the tests, and three unrelated…


What Is the Single Responsibility Principle?

SRP is the first of the five SOLID principles, formulated by Robert C. Martin. The original definition: “A class should have only one reason to change.”

That phrase is deceptively simple. “Reason to change” doesn’t mean “number of methods” or “lines of code.” It means the number of different stakeholders or concerns that could force a modification. If a security team, a marketing team, and a DBA can all independently require changes to the same class, that class has three reasons to change. It’s doing three different jobs.

A more practical restatement: a class should be responsible to one actor. One team, one concern, one domain of knowledge.


The Problem in Code

Here’s a User class handling registration. It looks manageable at first glance:

// User.java - four reasons to change
public class User {
    private String username;
    private String email;
    private String password;

    public User(String username, String email, String password) {
        this.username = username;
        this.email    = email;
        this.password = password;
    }

    // Reason 1: business rules change (validation format, password policy)
    public boolean validate() {
        if (username == null || username.isBlank()) return false;
        if (!email.contains("@"))                   return false;
        if (password.length() < 8)                  return false;
        return true;
    }

    // Reason 2: security team changes hashing algorithm (MD5 -> bcrypt -> Argon2)
    public String hashPassword(String rawPassword) {
        return MD5.hash(rawPassword); // simplified
    }

    // Reason 3: infrastructure changes (different mail provider, template engine)
    public void sendWelcomeEmail() {
        EmailClient.send(email, "Welcome, " + username + "!");
    }

    // Reason 4: database schema changes (new columns, different ORM, migration)
    public void save() {
        Database.insert("users", this);
    }
}

Four concerns in one class. A change to the email template touches the same file as a database migration. A password policy update sits next to SMTP configuration. Every change carries the risk of breaking something unrelated, and every test has to load the entire class to check any single behaviour.


The Solution, Step by Step

The fix is straightforward: extract each concern into its own class. User keeps what it owns: its data. Everything else finds a better home.

1. User holds only its own data

User is a data object. It knows its fields and nothing else. No logic, no side effects, no dependencies on external systems.

// User.java - data only
public class User {
    private final String username;
    private final String email;
    private final String password;

    public User(String username, String email, String password) {
        this.username = username;
        this.email    = email;
        this.password = password;
    }

    public String getUsername() { return username; }
    public String getEmail()    { return email; }
    public String getPassword() { return password; }
}

2. UserValidator handles validation rules

Validation rules change when business requirements change. That’s one concern, owned by one class. Tomorrow’s rule change touches only UserValidator.

Notice it validates raw input before the User object is constructed. There’s no reason to build an object just to immediately check if it’s valid.

// UserValidator.java - one reason to change: business rules
public class UserValidator {

    public boolean validate(String username, String email, String rawPassword) {
        if (username == null || username.isBlank()) return false;
        if (!email.contains("@"))                   return false;
        if (rawPassword.length() < 8)               return false;
        return true;
    }
}

3. PasswordHasher handles security logic

Switching from MD5 to bcrypt to Argon2 is a security decision. It should touch exactly one class and nothing else.

// PasswordHasher.java - one reason to change: security requirements
public class PasswordHasher {

    public String hash(String rawPassword) {
        // swap the algorithm here without touching User, validator, or the DB layer
        return BCrypt.hashpw(rawPassword, BCrypt.gensalt());
    }

    public boolean matches(String rawPassword, String hashed) {
        return BCrypt.checkpw(rawPassword, hashed);
    }
}

4. UserNotificationService handles communication

Changing the email provider, updating the welcome template, or adding an SMS fallback are all communication concerns. None of them belong near database code.

// UserNotificationService.java - one reason to change: communication
public class UserNotificationService {

    private final EmailClient emailClient;

    public UserNotificationService(EmailClient emailClient) {
        this.emailClient = emailClient;
    }

    public void sendWelcomeEmail(User user) {
        String subject = "Welcome to the platform!";
        String body    = "Hi " + user.getUsername() + ", thanks for signing up.";
        emailClient.send(user.getEmail(), subject, body);
    }
}

5. UserRepository handles persistence

Database schema changes, ORM migrations, connection pool tuning: all persistence concerns, all in one place.

// UserRepository.java - one reason to change: persistence
public class UserRepository {

    private final DataSource dataSource;

    public UserRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void save(User user) {
        String sql = "INSERT INTO users (username, email, password) VALUES (?, ?, ?)";
        try (PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql)) {
            stmt.setString(1, user.getUsername());
            stmt.setString(2, user.getEmail());
            stmt.setString(3, user.getPassword());
            stmt.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException("Failed to save user.", e);
        }
    }
}

6. Wire it together in a service

A thin UserRegistrationService orchestrates the flow. It coordinates the pieces but owns none of the logic itself.

// UserRegistrationService.java - orchestration only
public class UserRegistrationService {

    private final UserValidator           validator;
    private final PasswordHasher          hasher;
    private final UserRepository          repository;
    private final UserNotificationService notifications;

    public UserRegistrationService(
            UserValidator validator,
            PasswordHasher hasher,
            UserRepository repository,
            UserNotificationService notifications) {
        this.validator     = validator;
        this.hasher        = hasher;
        this.repository    = repository;
        this.notifications = notifications;
    }

    public void register(String username, String email, String rawPassword) {
        // validate raw input before constructing anything
        if (!validator.validate(username, email, rawPassword)) {
            throw new IllegalArgumentException("Invalid user data.");
        }

        // hash first, then build the User with the hashed password
        String hashed = hasher.hash(rawPassword);
        User user = new User(username, email, hashed);

        repository.save(user);
        notifications.sendWelcomeEmail(user);
    }
}

Why This Actually Works

Changes are isolated. The security team switches to Argon2. They open PasswordHasher, change one method, and close it. User, UserValidator, UserRepository: untouched. No risk of accidentally breaking email delivery while updating a hashing algorithm.

Testing becomes trivial. Before the split, testing validation meant constructing a User object that also knew how to connect to a database and send emails. Now UserValidator takes three strings and returns a boolean. The test is five lines. No mocks for SMTP, no in-memory database needed.

The codebase becomes navigable. When a new developer asks “where does password hashing happen?”, the answer is PasswordHasher. Not “somewhere in User, around line 40, but watch out for the email logic above it.” SRP makes the codebase self-documenting by structure.


When to Apply It and When to Pause

Watch for SRP violations when:

  • A class has methods that would be changed by different teams or for different reasons.
  • You find yourself writing “and” when describing what a class does.
  • A unit test requires mocking three unrelated dependencies just to test one method.

Pause before splitting when:

  • The class is small and the concerns are genuinely related, not just co-located.
  • Splitting creates so many tiny classes that the flow becomes harder to follow.
  • You’re splitting prematurely, before the second reason to change actually exists.

SRP is not about making classes small for the sake of it. A 200-line class with one coherent responsibility is fine. A 50-line class with three different concerns is not.


Wrapping Up

The original User class wasn’t badly written out of laziness. It was written the way most classes start: small, reasonable, and then slowly accumulated responsibilities as the feature grew. SRP is less about writing perfect classes from day one and more about recognising when a class has started doing too much, and having the discipline to split it.

Next up in this series: the Open/Closed Principle, where we look at what it actually means to extend behaviour without modifying existing code, and why getting this right makes adding features feel effortless.

Posted in solidTags:
Write a comment