Design is the art of managing complexity. In software design, the way we manage complexity is by making our code testable. All the good stuff you might have heard of - modularity, encapsulation, extensibility, reliability - is downstream from testability. The main way to make your software testable is to make it loosely coupled.
There are broadly two important ways in which you can make your code loosely coupled:
minimizing the surface areas for interaction, and
depending on interfaces rather than implementations.
Law of Demeter
The Law of Demeter, or the “Principle of Least Knowledge”, basically means that units should know as little as possible about other units. When crafting your code, this means that class surface areas should be as small (classes should be deep) as possible, and functions should accept as little as possible in their parameters.
For example,
type bar struct {}
func (b *bar) baz() {}
type foo struct {
b *bar
}
func (f *foo) getBar() *bar {}
func doBar(f *foo) {
f.getBar().baz()
}
struct Bar {
void baz() {}
}
class Foo {
std::shared_ptr<bar> mBar;
public:
std::shared_ptr<bar> getBar() {
return mBar;
}
}
void doBar(std::shared_ptr<foo> f) {
f->getBar()->baz();
}
would be a violation of the Law of Demeter because even though we only really needed baz
, we supplied foo
to the doBar
function, which also gave us access to bar
, which we did not need.
The above code is just an illustration, and in this case remains relatively understandable even with the violation. But over time these violations can compound and lead to extremely confusing code with tight coupling which will become hard to test effectively.
Dependency Inversion Principle
The Dependency Inversion Principle can be summarized as:
A
→B
Instead of
A
depending onB
,A
can choose to depend on an interfaceC
.A
→C
←B
This essentially decouples A
’s implementation from B
, which in turn makes it easier to test A
. One can now inject “mocked out implementations” of interface C
independently from B
. For example,
type C interface {
foo()
}
var _ C = &B{}
type B struct {}
func (b *B) foo() { // real implementation }
type A struct {
c C
}
func newA(c C) *A {
return &A{
c: c,
}
}
var _ C = &B{}
type mockB struct {}
func (b *mockB) foo() { // fake implementation }
func TestA(t *testing.T) {
a := A(&mockB{})
// Test A
}
class C {
virtual void foo() = 0;
};
class B : public C {
void foo() { // real implementation }
};
class A {
std::shared_ptr<C> mC;
public:
A(std::shared_ptr<C> c) : mC{c} {}
};
class MockB : public C {
void foo() { // fake implementation }
};
BOOST_AUTO_TEST_CASE(TestA) {
std::shared_ptr<C> b = std::make_shared<MockB>();
A a(b);
// Test A
}
Without the dependency inversion principle, testing can require instantiating the entire dependency graph, making testing in isolation and debugging nearly impossible.
The dependency inversion principle becomes extremely critical especially in cases such as where you have external dependencies such as a program written in another language. If you could not depend on interfaces instead of the other program, you’d have to rely on e2e testing rather than the far more desirable unit tests.
Thanks to Jared Newman, Vik Pattabi, and my other colleagues at Anduril for their feedback.