The topic of software architecture is discussed by teams time and again. Which architecture should we use? Clean architecture or hexagonal architecture? What are the differences again? And then there's this new IODA thing. Is it any good?
In this article, I would like to look at the structure of software and reduce it to two simple concepts: Responsibilities and Dependencies. Software consists of functional units, each with clearly defined responsibilities. These functional units are interdependent in order to provide the desired behavior of the system. The combination of responsibilities and dependencies results in the Structure of the software.
Responsibilities
The Single Responsibility Principle (SRP) states that a functional unit should only have one reason for changes. This is achieved by a functional unit only being responsible for one thing. Or vice versa: if a functional unit assumes more than one responsibility, there are also several reasons to make changes to this functional unit.
I consider the following terms to be synonymous in this context: accountability, competence, responsibility, aspect. All these terms essentially express the same thing. It is always about the question: what does a method do?
The term functional unit is used here as an umbrella term for Method and Class. The SRP can also be made larger and entire Libraries, Packages, Components or even Microservices consider. The umbrella term would then be Module. Here, however, I would like to focus on the code-related units for the sake of simplicity. Method and Class because the statements remain the same anyway. You can find more details on the term module in this article.
Limitation of responsibilities
Why is it so important to limit the responsibility of methods and classes? If we do not do this, the Changeability. And this is, besides the Correctnessthe most important thing that we as software developers need to focus on. Changeability means investment protection for our clients. They want to be able to make changes and additions to the software even after years and decades without the costs spiraling out of control.
If a method or class is responsible for more than one thing, it can be affected by changes for different reasons. Let's take a simple example: in a method, we read data from a file and split it into individual data records according to the rules of CSV.
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;
}
Looks simple but still contains an SRP violation. There are two reasons for changes using this method. One reason arises if the data suddenly no longer originates from a file but is read by a web service, for example. The other reason arises if the format changes. The data still comes from a file, but is now formatted in YAML, for example. The small code example is not dramatic. However, it shows where the problems begin.
None of us is stupid enough to write a method with hundreds of lines of code in one day. Nevertheless, such methods exist and violate the SRP much more drastically than the small example. How does this come about? My theory is that the existing structure of a method dictates how we as developers should behave in future if changes or additions need to be made to this method. If the existing structure is clean and tidy from the outset, there is less likelihood that the next hundred-liner will be created.
Let's note: if methods and classes are each responsible for exactly one thing, i.e. they only offer one reason for changes, we have achieved the first essential building block for changeability.
Dependencies
However, software systems are so complex that we cannot pack everything into one method or class. If we assume that each method and each class represents exactly one responsibility, the question remains as to how these functional units interact. After all, the desired functionality can only be achieved through the interaction of the individual aspects.
It follows directly from this that we have to bring the methods and classes into a structure that inevitably results in dependencies. Let's look at the example from above again. If we separate the responsibilities into a method for reading from the file and another for editing the content, the question arises as to how these two methods interact. We have two options for this:
- One method calls the other after it has completed its part of the work (DIP based).
- We provide an integrating method over the two methods (IOSP based).
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;
}
The difference appears to be small. On closer inspection, however, we recognize a mixture of responsibilities in the first example. ReadCsv is responsible for reading the data from the file. If this aspect changes, we change this part of the method. The other responsibility is outsourced to the CreateRecords method, but ReadCsv calls it and thus also assumes responsibility for the integration of the method.
In the second example, the responsibilities are clearly separated. ReadCsv integrates the two methods ReadFile and CreateRecords, while the other two methods are responsible for the respective logic to read from the file and create the records.
The following illustrations show the differences between the two structures.
Conclusion
Software architecture is actually quite simple: bring methods and classes with clear responsibilities into a meaningful dependency on each other.