Das Thema Softwarearchitektur wird immer wieder von Teams diskutiert. Welche Architektur sollen wir verwenden? Lieber Clean Architecture oder die Hexagonale Architektur? Was sind nochmal die Unterschiede? Und dann gibt es da doch noch dieses neue IODA Dings. Taugt das was?
In diesem Beitrag möchte ich die Struktur von Software betrachten und diese auf zwei einfache Konzepte reduzieren: Verantwortlichkeiten und Abhängigkeiten. Software besteht aus Funktionseinheiten mit jeweils klar definierter Verantwortlichkeit. Diese Funktionseinheiten stehen in Abhängigkeit, um das gewünschte Verhalten des Systems bereit zu stellen. Die Kombination aus Verantwortlichkeiten und Abhängigkeiten ergibt die Struktur der Software.
Verantwortlichkeiten
Das Single Responsibility Principle (SRP) besagt, dass eine Funktionseinheit nur einen Grund für Änderungen haben soll. Dies wird erreicht, indem eine Funktionseinheit nur noch für eine Sache verantwortlich ist. Oder andersherum: übernimmt eine Funktionseinheit mehr als eine Verantwortlichkeit, gibt es auch mehrere Gründe, an dieser Funktionseinheit Änderungen vorzunehmen.
Ich betrachte folgende Begriffe in diesem Kontext als synonym: Verantwortlichkeit, Zuständigkeit, Responsibility, Aspekt. Alle diese Begriffe drücken im Kern das gleiche aus. Es geht immer um die Frage: was macht eine Methode?
Der Begriff Funktionseinheit steht hier als Überbegriff für Methode und Klasse. Man kann das SRP auch größer fassen und ganze Bibliotheken, Pakete, Komponenten oder sogar Microservices betrachten. Der Überbegriff wäre dann Modul. Hier möchte ich mich jedoch zur Vereinfachung auf die codenahen Einheiten Methode und Klasse beschränken, weil die Aussagen ohnehin die gleichen bleiben. Weitere Details zum Begriff Modul findest Du in diesem Beitrag.
Begrenzung der Verantwortlichkeiten
Warum ist es so wichtig, die Verantwortlichkeit von Methoden und Klassen zu begrenzen? Tun wir das nicht, leidet die Wandelbarkeit. Und diese ist, neben der Korrektheit, das Wichtigste, was wir als Softwareentwickler in den Blick nehmen müssen. Wandelbarkeit bedeutet Investitionsschutz für unsere Auftraggeber. Diese wollen auch nach Jahren und Jahrzehnten Änderungen und Ergänzungen an der Software vornehmen, ohne dass die Kosten ins Unermessliche steigen.
Ist nun eine Methode oder Klasse für mehr als eine Sache verantwortlich, kann sie aus unterschiedlichen Gründen von Änderungen betroffen sein. Nehmen wir ein simples Beispiel: in einer Methode lesen wir Daten aus einer Datei und zerlegen diese nach den Regeln von CSV in einzelne Datensätze.
public List ReadCsv1(string path) {
var result = new List();
var lines = File.ReadLines(path);
foreach (var line in lines) {
var values = line.Split(";");
var record = new Record(values);
result.Add(record);
}
return result;
}
Sieht simpel aus und enthält dennoch eine SRP Verletzung. Es gibt für diese Methode zwei Gründe für Änderungen. Der eine Grund ergibt sich, wenn die Daten plötzlich nicht mehr aus einer Datei stammen, sondern bspw. von einem Webservice gelesen werden. Der andere Grund ergibt sich, wenn sich das Format ändern sollte. Die Daten kommen zwar immer noch aus einer Datei, sind nun aber bspw. YAML formatiert. Das kleine Codebeispiel ist nicht dramatisch. Es zeigt aber auf, wo die Probleme beginnen.
Niemand von uns ist so doof und schreibt an einem Tag eine Methode mit hunderten von Codezeilen. Trotzdem existieren solche Methoden und verletzen das SRP deutlich drastischer als das kleine Beispiel. Wie kommt es dazu? Meine These ist, dass der vorgefundene Aufbau einer Methode vorgibt, wie wir uns als Entwickler zukünftig verhalten, wenn an dieser Methode Änderungen oder Ergänzungen vorgenommen werden müssen. Ist der vorgefundene Aufbau von Anfang sauber und aufgeräumt, ist die Wahrscheinlichkeit geringer, dass der nächste Hundertzeiler entsteht.
Halten wir fest: sind Methoden und Klassen jeweils für genau eine Sache verantwortlich, bieten sie also nur einen Grund für Änderungen, haben wir den ersten wesentlichen Baustein für Wandelbarkeit erreicht.
Abhängigkeiten
Nun sind Softwaresysteme allerdings so komplex, dass wir nicht alles in eine Methode oder Klasse packen können. Wenn wir unterstellen, dass jede Methode und jede Klasse für genau eine Verantwortlichkeit steht, bleibt die Frage, wie diese Funktionseinheiten zusammenwirken. Denn nur durch das Zusammenspiel der einzelnen Aspekte ergibt sich die gewünschte Funktionalität.
Daraus folgt unmittelbar, dass wir die Methoden und Klassen in eine Struktur bringen müssen, aus der sich zwangsläufig Abhängigkeiten ergeben. Betrachten wir erneut das Beispiel von oben. Wenn wir die Verantwortlichkeiten trennen in eine Methode für das Lesen aus der Datei und eine weitere für das Bearbeiten des Inhalts, stellt sich die Frage, wie diese beiden Methoden zusammenwirken. Dazu stehen uns zwei Möglichkeiten offen:
- Die eine Methode ruft die andere auf, nachdem sie ihren Teil der Arbeit durchgeführt hat (DIP basierend).
- Wir stellen eine integrierende Methode über die beiden Methoden (IOSP basierend).
public List ReadCsv2(string path) {
var lines = File.ReadLines(path);
var result = CreateRecords(lines);
return result;
}
private static List CreateRecords(IEnumerable lines) {
var result = new List();
foreach (var line in lines) {
var values = line.Split(";");
var record = new Record(values);
result.Add(record);
}
return result;
}
public List ReadCsv3(string path) {
var lines = ReadFile(path);
var result = CreateRecords(lines);
return result;
}
private static IEnumerable ReadFile(string path) {
var lines = File.ReadLines(path);
return lines;
}
Der Unterschied scheint klein zu sein. Bei genauerer Betrachtung erkennen wir jedoch im ersten Beispiel eine Vermischung von Verantwortlichkeiten. ReadCsv ist dafür verantwortlich, die Daten aus der Datei zu lesen. Ändert sich dieser Aspekt, ändern wir diesen Teil der Methode. Die andere Verantwortlichkeit ist zwar ausgelagert in die Methode CreateRecords, doch ReadCsv ruft diese auf und übernimmt damit auch die Verantwortlichkeit über die Integration der Methode.
Im zweiten Beispiel sind die Verantwortlichkeiten klar getrennt. ReadCsv integriert die beiden Methoden ReadFile und CreateRecords, während die beiden anderen Methoden für die jeweilige Logik verantwortlich sind, um aus der Datei zu lesen und die Records zu erstellen.
Die folgenden Abbildungen zeigen die Unterschiede der beiden Strukturen.
Fazit
Eigentlich ist Softwarearchitektur ganz einfach: bringe Methoden und Klassen mit klarer Verantwortlichkeit in eine sinnvolle Abhängigkeit zueinander.