1. Overview

We’ve previously explored the Single Responsibility Principle (SRP) from a theoretical perspective and understood the problems it solves.

In this lesson, we’ll put that knowledge into action and apply SRP in our project. We’ll first look at examples where SRP is already applied correctly, and then identify a class that violates the principle and refactor it into a well-structured design where each class has a single reason to change.

The relevant module we need to import when starting this lesson is: srp-in-practice-start.

If we want to reference the fully implemented lesson, we can import: srp-in-practice-end.

2. The “Good” Example: SRP in our Project

Before we look for violations, let’s first examine some well-designed classes in our codebase that already follow SRP. Understanding what good design looks like will help us identify problems more easily.

Let’s examine how DefaultCampaignService and InMemoryCampaignRepository are each dedicated to a specific responsibility:

  • DefaultCampaignService: This class handles campaign-related business operations such as creating, retrieving, and managing campaigns. It serves the business layer, and if the rules for managing campaigns change, this is the class we’d modify.
  • InMemoryCampaignRepository: This class is solely responsible for data persistence operations. It knows how to store and retrieve Campaign entities but has no knowledge of business rules. If we decide to switch from in-memory storage to a database, we’d modify this layer without touching the service.

This separation ensures that each class changes for a different reason, allowing business logic and persistence concerns to evolve independently.

3. The “Bad” Example: A New Feature Violating SRP

Now that we’ve seen a good example where SRP is being followed, let’s examine a class that doesn’t follow SRP.

Let’s open the MonolithicCampaignService class present in the com.baeldung.lsol.service.impl package, which we included here specifically to showcase a common SRP violation:

public class MonolithicCampaignService {

    private CampaignRepository campaignRepository;

    // standard constructor

    public Optional<Campaign> closeCampaign(Long id) {
        return campaignRepository.findById(id)
          .map(campaign -> {
            campaign.setClosed(true);
            campaign.getTasks()
              .forEach(task -> {
                task.setStatus(TaskStatus.DONE);
              });
            return campaign;
          });
    }

    public void generateInvoice(Long id) {
        campaignRepository.findById(id)
          .ifPresent(campaign -> {
            // ... logic to calculate costs and notify finance team
            System.out.println("Generated invoice for " + campaign.getName());
          });
    }
}

This class was created to handle two requirements. The marketing team needed the ability to close campaigns, and the finance team needed a way to generate invoices for completed campaigns.

At first glance, this class might seem reasonable since both methods deal with campaigns. However, upon closer inspection, we can identify that this class serves two distinct actors with different reasons to change:

  • The closeCampaign() method handles a campaign lifecycle operation. This is business logic that the marketing team relies on. If they decide that closing a campaign should notify team members or not update task statuses, this method would need to change.
  • On the other hand, the generateInvoice() method serves an entirely different purpose. This is a financial operation that the finance team uses for billing. If they need to change how costs are calculated or how invoices are formatted, this method would need modification.

This class violates SRP because it has two reasons to change. Since both of these concerns belong to different actors, they should be separated.

4. Refactoring into the “Right” Place

Now that we’ve identified the violation, let’s refactor our MonolithicCampaignService class by separating its responsibilities and moving both of its methods into appropriate classes.

4.1. Campaign Lifecycle Logic

First, as we identified, the closeCampaign() method is a campaign lifecycle operation and belongs with other campaign management methods.

Let’s move it to our existing DefaultCampaignService class:

public class DefaultCampaignService implements CampaignService {

    // ... existing code

    @Override
    public Optional<Campaign> closeCampaign(Long id) {
        return campaignRepository.findById(id)
          .map(campaign -> {
            campaign.setClosed(true);
            campaign.getTasks()
              .forEach(task -> {
                task.setStatus(TaskStatus.DONE);
              });
            return campaign;
          });
    }
}

It’s important to remember that we need to add the corresponding method signature to the CampaignService interface as well:

public interface CampaignService {

    // ...

    Optional<Campaign> closeCampaign(Long id);
}

Now, all campaign lifecycle operations are grouped together in a single, cohesive class.

4.2. The Finance Logic

Next, since the invoice generation logic in the generateInvoice() method serves a different actor, we’ll move it into a dedicated service.

Let’s start by creating a new InvoiceService interface in the com.baeldung.lsol.service package:

public interface InvoiceService {

    void generateInvoice(Long campaignId);
}

Now, let’s create the DefaultInvoiceService class that implements this interface:

public class DefaultInvoiceService implements InvoiceService {

    private CampaignRepository campaignRepository;

    // standard constructor

    @Override
    public void generateInvoice(Long campaignId) {
        campaignRepository.findById(campaignId)
          .ifPresent(campaign -> {
            // ... logic to calculate costs and notify finance team
            System.out.println("Generated invoice for " + campaign.getName());
          });
    }
}

Now, our new class has a single, focused responsibility, which is invoice generation for campaigns. If the finance team needs additional operations like voiding invoices or generating bulk reports, we can add those methods here without affecting campaign management operations.

With this refactoring complete, we can now safely delete the problematic MonolithicCampaignService class from our project. Each responsibility now lives in its own dedicated class, and changes requested by one actor won’t risk breaking functionality used by another.

5. Conclusion

In this tutorial, we’ve put the Single Responsibility Principle into practice.

We started by examining existing well-designed classes in our codebase that already follow SRP, understanding how they serve different actors with focused responsibilities.

Then, we identified a class that violated the principle by handling both campaign management and invoice generation concerns.

Finally, we refactored the problematic class by moving the campaign logic to our existing service and creating a new dedicated service class for invoice operations. This separation ensures that each class has only one reason to change, making our codebase more maintainable and resilient.