Das Single Responsibility Principle (SRP)

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

Heute beginnen wir mit dem ersten SOLID–Prinzip: Eine Klasse sollte immer eine einzige Verantwortung haben, oder anders ausgedrückt: Es sollte nie mehr als einen Grund geben, eine Klasse zu ändern (Single Responsibility Principle, SRP).

Das SRP wendet man an, um übersichtliche, leicht erweiterbare und einfach zu wartende Software zu konzipieren.

Bei Klassen, die mehr als eine Verantwortung haben, entstehen folgende Nachteile:

  • Sie sind meistens so aufgebaut, dass bei Änderungen, die mit einer Verantwortung der Klasse zu tun haben, auch andere Bereiche beeinflusst werden. Dadurch läuft man Gefahr, unbewusst Fehler in die Software einzubauen.
  • Bei Rückbauarbeiten oder größeren Änderungen stößt man ebenfalls auf Probleme: Bei obsoleter Funktionalität muss man das gesamte Bild der betroffenen Klasse verstehen, um Änderungen anzugehen. Bei sehr komplexen Fällen wird dann sogar oft die Entscheidung getroffen, unbenutzten Code stehen zu lassen, um andere Funktionen durch unübersichtlichen Rückbau nicht negativ zu beeinflussen.
  • Funktionen, in denen mehrere Verantwortungsbereiche gemischt werden, können schwer oder gar nicht für andere Anwendungsfälle wiederverwendet werden.

Wenn man nicht auf das SRP achtet, kann man schnell in einen Teufelskreis geraten, in dem die Qualität der Software immer schlechter wird und der Aufwand für Änderungen steigt. Um einen solchen Teufelskreis zu vermeiden, ist es besser, derartige Probleme früh anzugehen oder von Anfang an zu vermeiden.

Schauen wir uns einmal folgendes Codebeispiel zu unserer SOLID-Blogserie an:

public class Customer implements Serializable {
            
    private Integer id;
    private String name;
    private int type;
    
    /*
     * getter und setter Methoden wurden zugunsten der Lesbarkeit entfernt
     */

    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 foundCustomer = 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;
    }    
}

Man kann schnell erkennen, dass die Persistenz und die Modellierung der Kundendaten zwei unabhängige Verantwortungen sind. Ein weiterer Hinweis darauf ist die statische „load“-Methode. Da sie nichts mit einer Instanz von „Customer“ zu tun hat, musste sie als statisch deklariert werden, um zu vermeiden, dass ein leeres „Customer“-Objekt angelegt werden muss, nur damit eines aus der Persistenz geladen werden kann.

SRP-Verletzungen sind bei existierendem Code nicht immer leicht zu durchblicken. Einen weiteren Hinweis gibt der Blick auf die Unit-Tests:

Es fällt sofort auf, dass der Test „CustomerTest“ heißt, weil die Klasse „Customer“ getestet wird. Die Testklasse scheint zwar die „Customer“-Modellierung zu testen, aber die Tests beschreiben das Speichern und Laden eines „Customer“-Objekts. Bei diesem Schema könnte man erwarten, dass andere Funktionen, die ebenfalls zum „Customer“ gehören, später in den gleichen Test kommen, und sicherlich auch in die gleiche Klasse. Dieses Schema verletzt also nicht nur das SRP-Prinzip, sondern es fordert Entwickler dazu auf, die Verletzung bei Änderungen zu verschlimmern. Bei jeder Änderung werden die Tests unübersichtlicher; wer sie sich anschaut, kann oft nicht genau das Ziel der Testklasse verstehen.

Eine Umstrukturierung unseres Codes nach dem SRP könnte zu folgendem Ergebnis führen:

public class Customer implements Serializable {
            
    private Integer id;
    private String name;
    private int type;    
    
    /*
     * getter und setter Methoden wurden zugunsten der Lesbarkeit entfernt
     */

}

public class CustomerPersistenceAdapter {
    
    private static final String PERS_TYPE = "cust";
    private SerializablePersister<Customer> persister;

    public CustomerPersistenceAdapter(SerializablePersister<Customer> 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;
    }

Wenn an eine generische Persistenz gedacht wird, muss diese von der „Customer“-Klasse abgelöst werden. Man bekommt so eine Klasse wie „SerializablePersister“. Diese Klasse hat als einzige Verantwortung, mit der Persistenz von „Serializable“-Objekten umzugehen; also müssen die Daten, die ein Objekt identifizieren, mitgegeben werden. Für diesen Fall haben wir die Felder „type“ und „id“ zur Identifikation eingefügt.

Daraus entsteht eine neue Klasse: „CustomerPersistenceAdapter“. Diese ist ausschließlich dafür verantwortlich, die unterliegende Persistenzschicht mit den richtigen Identifizierungsdaten eines „Customer“-Objekts aufzurufen.

Der Vorteil dieses Schemas ist, dass eine komplette Umstrukturierung der Persistenz auf „SerializablePersister“-Ebene (z.B. von einer Datei auf eine Datenbank) möglich ist, indem man nur den „SerializablePersister“ neu implementiert. Die anderen Klassen können unverändert bleiben, und das Risiko bleibt daher nur auf der veränderten Schicht. Wenn statt des „SerializablePersister“ ein anderes Persistenz-Schema eingesetzt werden soll, z.B. ein XML-Persister, so weiß man, dass die Änderung in diesem Fall auf der „PersistenceAdapter“-Ebene bleibt (in unserem Fall nur „CustomerPersistenceAdapter“) und nicht andere Bereiche des Systems beeinflusst.

Nun werden auch unsere Unit-Tests spannender. Da wir jetzt zwei Testklassen anlegen, werden zudem die unterschiedlichen Testziele sichtbar: beliebige Objekte zu speichern und das Speichern von „Customer“-Objekten zu testen. Die „Customer“-Klasse bleibt getrennt und dient als Modell eines „Customer“.

Daher ist es jetzt möglich, den „CustomerPersister“ ohne die spezifische Implementierung der Persistenz zu testen:

public class CustomerPersisterTest {

    CustomerPersistenceAdapter sut;
    private SerializablePersister<Customer> persister;
    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 diesem Test haben wir sichergestellt, dass genau das getestet wird, was „CustomerPersistenceAdapter“ tun soll: Daten eines Kunden für die Persistenz richtig auswerten und das unterliegende Persistenzframework mit den richtigen Daten aufrufen. Bei diesem einfachen Beispiel erscheint der Test überflüssig, aber bei wachsender Komplexität ist es oft wichtig, auch diese Adapterschicht mit Tests abzusichern.

Und der Test des „SerializablePersister“:

public class SerializablePersisterTest {

    private static final String SP_TYPE = "spTest";
    private static final String SAVED_STRING = "Saved String";
    
    private SerializablePersister<String> sut;
    private File saved;

    @Before
    public void init() {
        saved = new File("spTest1.bin");
        if(saved.exists()) {
            saved.delete();
        }
        sut = new SerializablePersister<String>();
    }
    
    @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());
    }
    

Dieser Test kann speziell die Funktionen des „SerializablePersister“ testen, ohne andere Prüfungen durchführen zu müssen.

Ein weiterer Vorteil ist, dass wir jetzt beliebig viele unterschiedliche Objekte persistieren können, ohne jedes einzelne mit der echten Persistenz testen zu müssen, da dieses Testziel in „SerializablePersisterTest“ gelöst ist. Damit kann bei Unit-Tests viel Zeit gespart werden.

Auch unsere Testauswertung ist jetzt aussagekräftig:

In echten Projekten ist es nicht immer einfach, die Verantwortungen von Klassen zu trennen. Selbst in unserem kleinen Beispiel kann die Frage auftauchen, ob das Speichern und das Laden im „SerializablePersister“ nicht aufgespalten werden sollten. Um mit dem SRP-Prinzip besser zu arbeiten, können folgende Fragen helfen:

  • Wenn ich eine Verantwortung „auftrenne“, inwiefern muss ich dann nach Änderungen an einer Klasse trotzdem die andere anpassen?
    • In unserem Beispiel: Wenn „save“ geändert wird, ist die Wahrscheinlichkeit groß, dass auch „load“ angepasst werden muss. Das liegt nicht an einem schlechten Design, sondern an der Tatsache, dass „save“ und „load“ eigentlich zur Verantwortung der „Persistenz“ gehören. Daher sollte man in diesem Fall nicht auftrennen.
  • Wenn folgende Fragen mit „Ja“ beantwortet werden, kann das ein Indikator dafür sein, dass das SRP verletzt wurde:
    • Wenn technische Änderungen im System nötig sind (z.B. eine Datenbankmigration), ist es wahrscheinlich, dass
      • Komponenten auf mehreren Schichten geändert werden müssen?
      • Komponenten, die Fachlichkeit implementieren, geändert werden müssen?
      • nicht klar einzuschätzen ist, wo nach der Änderung Probleme auftreten können?
    • Gibt es im System Codeblöcke, die bei neuen Funktionen kopiert und dann angepasst werden?
      • In unserem Beispiel: Bei der Originalversion würde sicherlich eine neue Klasse „Konto“ dazu führen, dass der Entwickler Teile von „Customer“ einfach übernimmt und so anpasst, dass ein Konto persistiert werden kann. Wenn man so weitermacht, wird es schnell unübersichtlich, wenn das Persistenzschema geändert werden soll. Beim korrigierten Beispiel braucht nur die Adapterschicht mit ihrer klaren Verantwortung für jedes Objekt implementiert werden.
    • Gibt es im System Klassen/Methoden/Komponenten, die sehr oft (wenn nicht bei jedem neuen Feature) angepasst werden müssen?
      • Ein typisches Beispiel kommt oft in Servletklassen vor. Wenn am Anfang ein „GeneralServlet“ definiert wurde, von dem alle erben sollen, und dort „generische“ Funktionen implementiert wurden, dann kann eine solche Klasse leicht Verantwortungen für z. B. „Navigation“, „Session“, „Benutzerrechte“, „Datenformatierung“ usw. teilen. Solche Knoten aufzulösen ist ein Schlüsselfaktor für eine gute Wartbarkeit.
    • Gibt es mehr als einen Grund, eine Klasse zu ändern?
      • In unserem Beispiel: Persistenz und Datenmodellierung sind zwei unterschiedliche Dinge, also auch zwei Gründe, um die alte Klasse „Customer“ zu ändern.
    • Hat die Klasse eine statische Methode?
      • Statische Methoden werden oft als solche deklariert, weil sie sonst nicht in die Klasse passen. In unserem Beispiel passt die „load“-Methode nicht in die Datenmodellierung.
    • Gibt es Testmethoden, die sehr viele Schritte machen oder gar Daten vorbereiten müssen, welche nicht direkt mit dem Testziel zu tun haben?
      • In unserem Beispiel haben wir eine „load“-Methode in der „Customer“-Klasse. Vom „Customer“-Konzept wird eigentlich nichts getestet.
  • Sind Testklasse und Testmethode aussagekräftig für das Testziel? Unklare Namen oder zu unspezifische Tests sind oft ein guter Hinweis auf getestete Klassen, die mehr als eine Verantwortung haben.

Die Komplexität einer Lösung sollte immer im Verhältnis zur Größe des Systems stehen. Daher sind Designentscheidungen über zukünftige Aspekte nur dann relevant, wenn man auch voraussehen kann, dass sie tatsächlich kommen werden. Anders gesagt: Immer auch auf das KISS (keep it simple stupid)-Prinzip achten!

Unsere Klassen sehen jetzt ein bisschen besser aus, aber es gibt noch andere Aspekte auf Klassenebene, die noch nicht berücksichtigt worden sind. Diese werden in den nächsten Teilen unserer SOLID-Serie im Detail erklärt!