The Single Responsibility Principle (SRP)

Java

The Single Responsibility Principle (SRP)

There should never be more than one reason for a class to change.

Today we start with the first SOLID principle: A class should always have a single responsibility, or in other words: There should never be more than one reason to change a class (Single Responsibility Principle, SRP).

The SRP is used to design clear, easily expandable and easy-to-maintain software.

Classes that have more than one responsibility suffer from the following disadvantages:

  • They are usually structured in such a way that changes that have to do with a responsibility of the class also affect other areas. This runs the risk of unknowingly installing errors in the software.
  • Problems are also encountered during deconstruction work or major changes: If functionality is obsolete, you have to understand the entire picture of the affected class in order to make changes. In very complex cases, the decision is often made to leave unused code so as not to negatively affect other functions through confusing dismantling.
  • Functions that mix multiple responsibilities are difficult or impossible to reuse for other use cases.

If you don't pay attention to the SRP, you can quickly end up in a vicious circle in which the quality of the software deteriorates and the effort required to make changes increases. To avoid such a vicious circle, it is better to deal with such problems early or to avoid them from the beginning.

Let's take a look at the following code example from our SOLID blog series:

public class Customer implements Serializable {

private integer id;
private String name;
private int type;

/*
* getter and setter methods have been removed for readability
*/

public void save () throws IOException {
File customerFile = new File("cust" + id + ".bin");
try (FileOutputStream fileOutputStream = new FileOutputStream(customerFile);
ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream)
) {
objectOutputStream.writeObject(this);
catch (IOException ioEx) {
throw new RuntimeException("Error saving: " + ioEx.getMessage());
}
}

public static Customer load(Integer id) throws IOException, ClassNotFoundException {
File customerFile = new File("cust" + id + ".bin");
Customer found Customer = null;
try (FileInputStream fileInputStream = new FileInputStream(customerFile);
ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream)
) {
foundCustomer = (Customer) objectInputStream.readObject();
catch(FileNotFoundException fileNotFoundEx) {
throw new RuntimeException("File not found: " + fileNotFoundEx.getMessage());
catch (IOException ioEx) {
throw new RuntimeException("Error loading: " + ioEx.getMessage());
catch (ClassNotFoundException classNotFoundEx) {
throw new RuntimeException("Class not found: " + classNotFoundEx.getMessage());
}
return foundCustomer;
}
}

One can quickly see that persistence and modeling of customer data are two independent responsibilities. Another clue to this is the static "load" method. Since it has nothing to do with an instance of Customer, it had to be declared static to avoid having to create an empty Customer object just so one can be loaded from persistence.

SRP violations are not always easy to spot in existing code. A look at the unit tests gives another hint:

It is immediately noticeable that the test is called "CustomerTest" because the class "Customer" is being tested. Although the test class appears to test "Customer" modeling, the tests describe saving and loading a "Customer" object. With this scheme, one might expect that other functions that also belong to “Customer” would end up in the same test later, and certainly in the same class as well. So not only does this scheme violate the SRP principle, but it encourages developers to make the violation worse when making changes. With each change, the tests become more confusing; those who look at them often cannot exactly understand the aim of the test class.

Restructuring our code following the SRP could lead to the following result:

public class Customer implements Serializable {

private integer id;
private String name;
private int type;

/*
* getter and setter methods have been removed for readability
*/

}

public class CustomerPersistenceAdapter {

private static bottom String PERS_TYPE = "cust";
private SerializablePersister persisters;

public CustomerPersistenceAdapter(SerializablePersister sp) {
this.persister = sp;
}

public void save(Customer cust) throws IOException {
persister.save(cust, PERS_TYPE, cust.getId());
}

public Customer load(Integer id) throws IOException, ClassNotFoundException {
return persister.load(PERS_TYPE, id);
}
}

public class SerializablePersister<T extends Serializable> {

public void save(T obj, String type, Integer id) {
File customerFile = new File(type + id + ".bin");
try (FileOutputStream fileOutputStream = new FileOutputStream(customerFile);
ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream)
) {
objectOutputStream.writeObject(obj);
catch (IOException ioEx) {
throw new RuntimeException("Error saving: " + ioEx.getMessage());
}
}

public T load(String type, Integer id) {
File customerFile = new File(type + id + ".bin");
T foundObject = null;
try (FileInputStream fileInputStream = new FileInputStream(customerFile);
ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream)
) {
foundObject = (T) objectInputStream.readObject();
catch(FileNotFoundException fileNotFoundEx) {
throw new RuntimeException("File not found: " + fileNotFoundEx.getMessage());
catch (IOException ioEx) {
throw new RuntimeException("Error loading: " + ioEx.getMessage());
catch (ClassNotFoundException classNotFoundEx) {
throw new RuntimeException("Class not found: " + classNotFoundEx.getMessage());
}
return foundObject;
}

}

If generic persistence is considered, it must be replaced by the "Customer" class. You get a class like "SerializablePersister". This class has sole responsibility for handling the persistence of Serializable objects; so the data identifying an object must be given. For this case we have added the fields "type" and "id" for identification.

This creates a new class: "CustomerPersistenceAdapter". This is solely responsible for calling the underlying persistence layer with the correct identification data of a "customer" object.

The advantage of this scheme is that a complete restructuring of the persistence at the "SerializablePersister" level (eg from a file to a database) is possible by just re-implementing the "SerializablePersister". The other classes can remain unchanged and the risk therefore remains only on the changed layer. If another persistence scheme is to be used instead of the "SerializablePersister", eg an XML persister, then you know that the change in this case remains on the "PersistenceAdapter" level (in our case only "CustomerPersistenceAdapter") and not affects other areas of the system.

Now our unit tests are getting more exciting. Since we are now creating two test classes, the different test goals are also visible: to save any objects and to test the saving of "Customer" objects. The "Customer" class remains separate and serves as a model of a "Customer".

Therefore it is now possible to test the "CustomerPersister" without the specific implementation of persistence:

public class CustomerPersisterTest {

CustomerPersistenceAdapter sut;
private SerializablePersister persisters;
private Customer customer;

@SuppressWarnings("unchecked")
@Before
public void init() {
persister = Mockito.mock(SerializablePersister.class);
sut = new CustomerPersistenceAdapter(persister);

customer = new customer();
customer.setId(1);
customer.setName("Max Muster");
}

@Test
public void canSaveCustomer() {

sut.save(customer);

Mockito.verify(persister, Mockito.times(1)).save(customer, "cust", customer.getId());
}

@Test
public void canLoadCustomer() {

Mockito.when(persister.load("cust", 2)).thenReturn(customer);

Customer loaded = sut.load(2);

Mockito.verify(persister, Mockito.times(1)).load("cust", 2);
assertTrue(loaded == customer);
}

}

In this test, we made sure to test exactly what CustomerPersistenceAdapter is supposed to do: correctly evaluate a customer's data for persistence and invoke the underlying persistence framework with the correct data. In this simple example, the test seems superfluous, but as complexity increases, it is often important to also secure this adapter layer with tests.

And the test of the "SerializablePersister":

public class SerializablePersisterTest {

private static bottom String SP_TYPE = "spTest";
private static bottom String SAVED_STRING = "Saved String";

private SerializablePersister like
private File saved;

@Before
public void init() {
saved = new File("spTest1.bin");
if(saved.exists()) {
saved.delete();
}
sut = new SerializablePersister ();
}

@Test
public void saveAndFileExists() throws IOException {

sut.save(SAVED_STRING, SP_TYPE, 1);
assertThat(saved.exists(), is(true));
}

@Test
public void loadReturnsCorrectValue() throws IOException, ClassNotFoundException {
sut.save(SAVED_STRING, SP_TYPE, 1);

String loaded = sut.load(SP_TYPE, 1);

assertThat(loaded, notNullValue());
assertThat(loaded, is(SAVED_STRING));
}

@Test
public void loadNonExistentReturnsNull() throws IOException, ClassNotFoundException {
String loaded = sut.load("SP_TYPE", 3);

assertThat(loaded, nullValue());
}

}

This test can specifically test the functionality of the SerializablePersister without having to do other tests.

Another advantage is that we can now persist any number of different objects without having to test each one with real persistence, since this test objective is solved in "SerializablePersisterTest". This can save a lot of time during unit tests.

Our test evaluation is now meaningful:

In real projects, it's not always easy to separate responsibilities from classes. Even in our small example, the question can arise whether saving and loading should not be split in the "SerializablePersister". In order to work better with the SRP principle, the following questions can help:

  • If I "unzip" a responsibility, how do I still need to adjust the other class after making changes to one class?
    • In our example: If "save" is changed, there is a high probability that "load" also needs to be adjusted. This is not due to bad design, but to the fact that "save" and "load" actually belong to the responsibility of "persistence". Therefore, in this case, you should not separate.
  • If the following questions are answered with "Yes", this can be an indicator that the SRP has been violated:
    • If technical changes are necessary in the system (e.g. a database migration), it is likely that
      • Components on multiple tiers need to be changed?
      • Components that implement functionality need to be changed?
      • it is not clear where problems can arise after the change?
    • Are there code blocks in the system that are copied for new functions and then adapted?
      • In our example: In the original version, a new “Account” class would certainly mean that the developer simply takes over parts of “Customer” and adapts them in such a way that an account can be persisted. If you continue like this, it quickly becomes confusing when the persistence scheme is to be changed. In the corrected example, only the adapter layer needs to be implemented with its clear responsibility for each object.
    • Are there classes/methods/components in the system that have to be adjusted very often (if not with every new feature)?
      • A typical example often occurs in servlet classes. If a "GeneralServlet" was defined at the beginning, from which everyone should inherit, and "generic" functions were implemented there, then such a class can easily be responsible for e.g. B. "Navigation", "Session", "User Rights", "Data Formatting" etc. Resolving such knots is a key factor for good maintainability.
    • Is there more than one reason to change a class?
      • In our example: persistence and data modeling are two different things, so also two reasons to change the old class "Customer".
    • Does the class have a static method?
      • Static methods are often declared as such because otherwise they don't fit into the class. In our example, the "load" method does not fit into the data modeling.
    • Are there test methods that have to take a lot of steps or even prepare data that are not directly related to the test objective?
      • In our example, we have a "load" method in the "Customer" class. Nothing of the “customer” concept is actually tested.
  • Are the test class and test method meaningful for the test objective? Ambiguous names or tests that are too vague are often a good indication of tested classes that have more than one responsibility.

The complexity of a solution should always be in proportion to the size of the system. Therefore, design decisions about future aspects are only relevant if one can also foresee that they will actually come. In other words: Always pay attention to the KISS (keep it simple stupid) principle!

Our classes are looking a bit better now, but there are other aspects at the class level that haven't been addressed yet. These will be explained in detail in the next parts of our SOLID series!