I've only so much room in my head
Increasingly I’ve been spending more and more of my time as a developer striving to understand the system I’m working in at the appropriate level of understanding - a higher percentage than I spend in the act of writing software itself. I find that if I’ve not got a good understanding of the problem space I either end up building a solution that just hits a local maxima, or I can’t even begin to come up with a solution.
It’s always a smell when it’s overly difficult to achieve some simple result - something that seems quick and easy to do but turns out really difficult often means it’s poorly designed. How can we get around this? I am a big believer in taking time to think more deeply about a problem, and the possible way we could describe that in code means we can avoid bad abstractions, delegate doing work until later and come up with simple, extensible solutions. I generally follow the process below.
Step 1: Draw out your understanding
I draw out the relevant parts system design as I understand it before beginning work. This isn’t a full system sketch, and it sure isn’t formal UML. Just a written representation of how I see things. I use a whiteboard or a big sheet of paper.
Step 2: Trace the code path(s)
I take some time to walk through the code as it might execute. I note down a few different things that I find are important to understanding:
- Where are the conditionals?
- Where does this code get it’s inputs from?
- Is some input read more than once?
- Where are the state mutations?
- What code is surprising (magic methods fit here)?
- Non-idempotent objects, where their internal state changes how they act.
- Implicit interfaces (including inter-service calls)
Note that these questions should be asked in a quite general way. It’s easy to point at a function and say “here are the arguments” and assume that’s all the inputs - but any call out to a different part of the codebase, for example, a fetch of the
current_user is also an input - it has coupled a possible database read and this code. If that user is referenced multiple times, then lots of distinct code elements are coupled to it. It’s also important to look at the general code structure; often there’s a a veneer of refactoring/DRYing/inheritance that doesn’t fundamentally alter the fact that different concepts are tangled together.
This tracing doesn’t stop at service boundaries, calls between services often are subject to a lot of complexity. Calls to frameworks are also couplings. Just because the implementation isn’t directly checked into your source control doesn’t mean it doesn’t exist.
Step 3: Erase your first drawing and write it out again with your new understanding
This should show up how tangled things are, and where making a change might affect many different parts of the system. Take a good long look at this drawing, and try to see where the biggest problems lie; where some connection does not belong. How might that be changed?
Step 4: Despair at how difficult this is
Oftentimes I find the last step of untangling everything to be unreasonably difficult. There’s often so much of the system required to be in your head at once that it’s almost impossible to hold it all inside. For me, this is the largest downside of large frameworks. To run some code in a “best-practises” RoR environment, you have to understand how the coupling with the framework (and hence, databases) works, how the magic methods and “conventions” work, how the test framework magically works.
If you have this framework hell, and on top of that you have really complicated, brokenly-connected systems, I don’t believe it’s possible to make effective changes. We only have the capacity to keep a small amount in our heads at a time, and if we can’t fit the entire problem space/subsystem in there at once we make bad choices, writing poor code, and making things worse.
Step 5: Things we can do
Take your diagram and try and find where you could make one change that makes something simpler. For example, if you’re looking up some state in a couple of places and making two different decisions based on that, how about changing the flow to make the two decisions in the same place and passing that decision along to be used later?
Simplify ruthlessly. Don’t pick the easy option, pick the simple one.
Making correct abstractions is hard. Avoid any large abstraction, avoid large interfaces. Duplication is cheaper than the wrong abstraction.
Think in terms of the problem, not in terms of the technology or framework. Don’t mention ActiveRecord when deciding behaviours. Architect cleanly.
Avoid having to think at different levels. When I’m writing code to generate nginx configurations I shouldn’t have to worry about framework parsing libraries.
Getting to simple is hard, and often goes against the obvious first step. There’s always a natural tension between shipping things quickly and shipping things well. In the long term though, it’s much much more valuable to be able to build on top of simple elements. Complexity kills velocity.