The single responsibility principle asks us to keep together the code that changes for the same reason and separate code that changes for different reasons. A class can have several methods and still follow this principle when those methods support one responsibility.
For example, storing employee records and sending welcome emails are different responsibilities. Database changes affect how we store employees, while email requirements affect how we deliver messages. We can give each responsibility its own class and let a registration service coordinate them.
SRP is one of the five SOLID principles. It helps us decide where a responsibility belongs, rather than setting a limit on the number of methods a class may contain.
1. What Is a Responsibility?
A responsibility is a related part of the application’s work. For example, an employee store may add employees, find them and update their details. These are several methods, but they all support the same responsibility of storing and retrieving employee records.
An email sender has a different responsibility. It deals with delivering messages. Its behavior may change when we replace an email provider, while the store may change when we alter the database mapping.
The diagram also includes a registration service. This service coordinates the registration process by calling the store and the email sender. Coordination is useful work in its own right. We do not have to put the database and email implementations into the service just because it uses them.
In Robert C. Martin’s explanation of SRP, a reason to change is connected to the people or business function requesting the change. This helps us distinguish independent requirements from ordinary edits to a method.
2. A Class With Unrelated Responsibilities
Consider this initial interface. Employee represents an employee record. IEmployeeStore exposes both storage operations and an email operation.
interface IEmployeeStore {
Employee getEmployeeById(long id);
void addEmployee(Employee employee);
void sendEmail(Employee employee, String content);
}
If welcome messages must support both HTML and plain text, we may need to extend the email operation to describe the content type. Even if the signature stays the same, email-related changes still affect the implementation of a component presented as an employee store. A move to a different email provider introduces another unrelated reason to edit it.
The problem is the relationship between these changes. Getting an employee from storage does not depend on the format of a welcome email, yet the interface brings them together. The class name also becomes misleading because it promises storage while exposing communication behavior.
This is a stronger reason to split the class than simply saying it has three methods. A storage class with ten related methods may be more cohesive than a class with two unrelated methods. Cohesion describes how closely a class’s data and behavior belong together.
3. Separate Storage From Email Delivery
We remove the email method from the storage interface and introduce IEmailSender. The sender accepts a message, including its content type, so storage code does not need to know about HTML or plain text.
The following declarations describe the boundaries between the components. Database access and email provider implementations belong behind these interfaces. The examples use Java 17 or later. A record is a compact data carrier that provides accessors such as employee.name() and employee.email() automatically.
record Employee(long id, String name, String email) {}
record EmailMessage(String subject, String body, String contentType) {}
interface IEmployeeStore {
Employee getEmployeeById(long id);
void addEmployee(Employee employee);
}
interface IEmailSender {
void sendEmail(String recipient, EmailMessage message);
}
The storage interface no longer declares an email operation. Its implementations can focus on employee records. The sender can handle delivery details without changing the store’s contract.
We use interfaces here to make the boundaries visible. SRP does not require an interface for every class. If there is no useful need to swap an implementation or hide a dependency, two ordinary classes can provide the same separation.
4. Coordinate the Registration Process
After separating the jobs, we still need somewhere to call them. EmployeeRegistrationService represents the application operation that registers an employee and sends a welcome message.
final class EmployeeRegistrationService {
private final IEmployeeStore employeeStore;
private final IEmailSender emailSender;
EmployeeRegistrationService(
IEmployeeStore employeeStore, IEmailSender emailSender) {
this.employeeStore = employeeStore;
this.emailSender = emailSender;
}
void register(Employee employee) {
employeeStore.addEmployee(employee);
EmailMessage message = new EmailMessage(
"Welcome",
"Hello " + employee.name() + ", your account is ready.",
"text/plain");
emailSender.sendEmail(employee.email(), message);
}
}
The constructor receives the two dependencies. The service calls addEmployee() first and sendEmail() second. We can read the registration flow without reading database statements or email provider code.
This small example does not make the two operations atomic. If storage succeeds and email delivery fails, the employee may already exist. A production application needs a deliberate policy for that failure, such as recording a pending notification and retrying delivery. Moving methods into separate classes does not solve that problem by itself.
If message construction later becomes complicated, a dedicated message builder can own it. We do not need that extra class merely to assemble this short greeting.
5. When Should We Split a Class?
We can look at recent or expected changes and ask a few practical questions.
- Do different requirements cause us to edit unrelated parts of this class?
- Does a change to one feature force users of another feature to change?
- Does the class need dependencies that only a small, unrelated group of methods uses?
- Can we give each proposed class a clear purpose without creating awkward conversations between them?
If the methods operate on the same data and change together, keeping them together may be simpler. Splitting every method into a separate class can make a straightforward operation harder to follow.
SRP also differs from the open-closed principle. SRP helps us decide what belongs together. OCP helps us decide how to add a new variation without repeatedly editing stable behavior.
6. Conclusion
The useful question is why a class needs to change. Employee storage and email delivery have different reasons, so we separate them and let a registration service coordinate the workflow. We keep related methods together and introduce additional classes when they make those responsibilities clearer.
Happy Learning!
I guess the “IEmailContent content” inside IEmailSender.java and EmailSender.java should be EmailContent content.
Like below:
IEmailSender.java
public interface IEmailSender
{
public void sendEmail(Employee employee, EmailContent content);
}
EmailSender.java
public class EmailSender implements IEmailSender
{
@Override
public void sendEmail(Employee employee, EmailContent content) {
//logic
}
}
Please correct if I am wrong: This code is driving me crazy! I am a n00b trying to understand it.
Dear author,
Could you please provide some more details about the usage of IEmailSender inside EmployeeStore class?