In this write-up we shall have a quick look at why handling dependencies properly is important for any software system. This is just a distillation of what I have learned from different resources trying to learn ways of managing software complexity. The ideas discussed have been around long and I have just tried to write down my understanding and put together a quick guide on how to make use of them.
Ideas of Software and System are used interchangeably as the difference does not matter for this write-up.
What is the most important singular problem in Software Design?
Coping with change. Why should software be changed? Simply because it is inevitable. In other words, since software is how-to knowledge of things, and the why-s and what-s of a software change for it to be useful in ever changing world, the how-s also should change or improve.
Why is change difficult in a system?
Change is difficult and expensive when the system is Rigid, Fragile, Immobile and Viscous1.
Rigidity is when a change done in one part of the system has ripple effects in other parts and so every change becomes prohibitively tedious and expensive. This inability to make any adjustments to the system in an easy way gives rigid appearance to the system.
Fragility is closely related to Rigidity in that every change to the system breaks something else potentially unrelated even, to break.
Immobility is when no component in isolation can be reused. If pulling a component to be used elsewhere brings with it a load of unrelated dependencies, the components in the system are immobile and there is no reusability.
Viscosity happens when it is easy and straightforward to do a hacky solution in the system than doing the right solution. This causes the hacky solutions to pile up to an extent that the entire system is hacky and design erodes to a point of uselessness.
Why would a system become rigid, fragile, immobile and viscous?
Because of dependencies among objects in the system and how those dependencies are handled.
Dependencies
Say there are 2 objects A and B and A is dependent on B. Any object has reasons to change in its lifetime, say it gets new features, bugs in it get fixed or refactored. If Object A has to change because Object B changed due to reasons stated above, this is tight coupling and the more of such couplings in the system, it is more difficult to change that system.
So what are the techniques to design software so that objects in it stay open to extension and closed for modification while also are less rigid, fragile, immobile and viscous. There are many and we look here at 2 most important and related ideas.
Single Responsibility and Dependency Injection
Single Responsibility means that when we try to explain what a method does, if there are conjunctions AND or OR in the explanation, the method does more than one thing in increasingly bad effects. Eg: This method downloads a file AND parses the file AND stores the parsed contents in a database.
Dependency Injection means that when an object has a dependency hard coded in it - like (pseudo-code)
class A {
A() {
B b = new B();
}
}
In code above, there is no way to change B for a DifferentB or ImprovedB. This class is closed for extension and modification only can help achieve desired changes. If instead, it was written like below -
Class A {
A(BInterface bObj) {
b = bObj;
}
BInterface b;
}
The benefits are
bObj can take any of B, DifferentB or ImprovedB which means it is perfectly open for extension.
Since object A is dependent on interface of B, with a right level of abstraction at the interface, object A will not have to change ever when the implementation details of B change.
And most importantly, bObj can be mocked for testing
When combined with Single Responsibility Principle, Dependency Injection helps achieve a system in which the components are reusable, changes can be made without breaking the system inadvertently.
Musing: The idea of Dependency Injection seems to be parallel to higher order functions recommended by Functional Programming paradigm, where, as we go higher up in the level of abstraction, the higher level structures work with lower level structures with a contract at function signature. Eg: map(fn, list) can work with any function and list, provided the function has a contract that it accepts a value and returns a value. Dependency Injection seems to recommend this in a lateral direction where the abstraction doesn’t move necessarily upwards.
Unit Testing
What are the effects of dependencies of code that gets tested to the code that actually tests them i.e. from the object to corresponding tests. It is the same effect in that if the tests are too tightly coupled with details of the object being tested, every time the object implementation details change the tests break making unit testing a bottleneck for improving the system Sandi Metz2 recommends following tricks to handle this -
Test only the public interface of the object taking into account whether the methods getting tested are Query (no side effects) or Command (with side effects)
Do not test internal (private) methods as they are by definition implementation details and testing them only increases the coupling of tests and the object.
Test outgoing calls from an object at the API level i.e. just to ensure that the call is made and not the effects of the call are achieved. Eg: if an object X calls object Y that launches a rocket, testing object X should cover only the fact that it calls Y and not that a rocket was in fact launched. That should be covered in unit tests of Y.
Be mindful that Stubs and Mocks are different
Ensure that tests using Mocks take care of “API drift”
Ideally, none of the tests should break if the interface of the object doesn’t change. If there are tests that break when the implementation details change, delete them.
Footnotes:
2 - https://www.youtube.com/watch?v=URSWYvyc42M