Making good design decisions (in the software engineering sense) is tricky. In theory, you can boil everything down to first principles and make a decision based in pure logic. But in practice, this tends not to happen very much outside of textbooks. Real projects are just too complex and messy. And many design principles exist to make your life easier in the future: making the code more understandable, easier to extend, easier to fix, easier to modify. Even a logical argument has to begin with some hypothetical future situations, so it’s not purely logical.
Although good design can be justified by logic after the fact, getting good at it in practice requires you to develop the right intuition and gut instincts: what you might call good taste. Most people acquire it by making bad design decisions and being forced to live with the consequences, learning from their mistakes. It’s an experience thing.
So how can you accelerate your learning? How do you make better decisions when your instincts aren’t fully developed? How do you make sure you develop good intuitions?
Here’s another related problem. How many times have you come across an older developer who makes an awful design decision, but who stubbornly defends it and plays the infuriating “I have more experience” card to dismiss you? It’s unfortunate, but people can still have poor intuition despite years of experience. Maybe they don’t learn from their mistakes very well, or maybe it’s in the nature of their experience: they’ve learned so much about a particular set of development circumstances (say, 8-bit games made by 2 people in a few months using assembler) that their gut instincts just aren’t right for the current situation (say, cutting edge games written by 100 people over several years in C++).
These debates are near-impossible because you each favour your own gut instincts, and it’s hard to explain or justify them. You may not even recognise your own gut instincts as such. You just think the other person’s wrong and you can’t explain why. You can try appealing to logic, but you typically have to invoke hypothetical future situations or past mistakes to do so, and the two of you are likely to come up with different scenarios to support your respective arguments.
I recently discovered a rather wonderful one-word answer to both these problems: “testability”. I’ve known for years that unit testing is a Good Thing, but I only just realised that “testability” appears to be a neat little rule of thumb to approximate local design quality! Specifically, making code testable tends to promote the following design principles:
- Loose coupling – because unit tests only work when they can isolate a small piece of code to test.
- Minimising dependence on state (especially super-evil global state!) – because setting up state for test code is tedious and error-prone.
- Simplicity and ease of use – because if your API is awkward to use, writing test code for it quickly brings home that point in painful fashion.
- Separating interface from implementation – because this is key to “mocking”.
- Code reuse – because the test harness provides a rather different “user” for your code. The more “users” you successfully add, the more reusable your code becomes.
- Extensibility – because writing tests is easier if your code provides hooks to inspect and tweak what it’s doing, and these hooks tend to lend themselves to extensibility too.
No doubt there are more: check out this excellent article by Miško Hevery of Google.
So if you simply want to improve and develop your intuitions (and your design!), you can ask yourself “what are the implications for testability?” The really nice thing is that it doesn’t take long to write a bit of test code and see for yourself: you can check your answer.
Equally, suppose you commit as a team to unit testing of your codebase. Now, when you’re faced with a developer who’s designed an awful API and is stubbornly defending it, you can beat them over the head with the testability stick (figuratively speaking). Testability cuts through a lot of the judgement and intuition and makes the discussion concrete: a couple of small test harness code fragments quickly show which approach is better. “Testability” feels more binary where “good design” feels fuzzy. This doesn’t help you with larger architectural issues, but getting your code in good shape locally is well worth it.
Of course, unit testing has many other benefits – but this was a pleasantly surprising side effect for me🙂