Overly strict TDD and SOLID are this generation's factory pattern and XML. If you don't know what I mean by that, open a piece of "enterprise grade" code from the early 2000s (you do have a stash of that kicking around, right?) That boilerplate-laden J2EE or .net 1.0 code is full of things like MessageBoxFactory and ReportLogicBlockFactory and AbstractUIElementFactoryAbstractFactory. Of course a misused design pattern is no fun by itself, so when you look inside the LayoutFactoryFactory it turns out to do its work by ingesting huge chunks of XML and trying to reflect that into classes and assemblies without validation or error checking.
Thankfully, things got better. Collectively, we realised that abstracting the messy details of object instantiation away didn't really help if the net result was logic being strewn across dozens of factory classes and untestable configuration files reeking of inner platform effect.
Unfortunately, we then proceeded to make exactly the same mistake again. Open up a modern piece of "enterprise grade" code and what does it look like? Reams of interfaces and objects passed all over the place in support of a towering mass of tightly-coupled test code. Dozens of layers stuffed with tiny classes in deference to the strictest possible interpretation of "single responsibility". And usually somewhere in the back of it a horrible spaghetti mess of wiring that tells the IoC container what all those interfaces actually correspond to in the real world.
In terms of maintainability, we've gone nowhere. In 2004, if I went hunting for a piece of logic I'd eventually find it either in some unrelated factory or XML file. In 2016, I'll find it either split between three different classes or somewhere in the IoC wiring.
And if I change that logic? In 2004, unrelated bits of the application would blow up and I wouldn't know about it without an exhaustive manual test. In 2016, hundreds of brittle and tightly-coupled tests will go red. And then unrelated bits of the application will still blow up because what's actually being tested is more implementation detail than result.
What we're seeing here is that while we learned the concrete lesson of the early 2000s - "trying to fit everything into the factory pattern is bad" - we've completely missed the abstract one:
- Blindly following any pattern to an extreme is bad.
There's a secondary factor as well - if you look again at some of the worst abuses of the factory pattern perpetrated a decade or so ago, you'll see that most of the so-called "factories" aren't really factories at all; they conform only in the most notional sense that somewhere in there is a method which kicks out an object.
I see the same thing in the worst excesses of modern code. In generic terms, it's this:
- As a pattern is followed to an extreme, it will be perverted to easier support extremism.
The Single Responsibility Principle is a great example of this. Have a read of Uncle Bob talking about "responsibility" and "reason to change", then think about how SRP is actually applied in most codebases today. It's not just me spinning some typical cowboy bullshit that if a class is only 50 lines of code you probably don't need to spend a week slicing it into layers for every notional responsibility you can come up with - this is the guy who came up with the term telling you it's nothing more than this: each class should only have one person who could make requests that require changing it, and if you can guarantee that you're pretty much golden on SRP.
It follows that SRP is therefore actually about business reason for change. Slicing things up along technical/functional lines isn't helping with that, and may well be anti-SRP (as Uncle Bob describes it) by putting you in a place where no matter who requests a change, you have to touch the business logic layer and the validation layer and repository layer and the domain layer and the "nobody knows what it does, but it sure does a lot of mapping" layer and the YAGNI-ridden "we thought we might have this sort of logic but then we didn't write any" layer - and then delve in the monster IoC wiring class because you probably broke something in there by accident.
(But hey, that monster IoC wiring class only has the one responsibility of specifying instantiation behaviour, right? Can we pat ourselves on the back for "doing SRP" now?)
What are we actually trying to do here?
I think this happens because, as developers, we love rules and patterns. Applied well they make our lives easier. The problem comes when we shift our mental focus from, "making our lives easier is good" to "using patterns is good". Because at that point, you're no longer thinking about how to avoid replicating object instantiation code all over the application, you're thinking about how you can turn everything into a factory.
It's sometimes difficult to come back up for air and think, "hold on a second, what am I actually trying to solve here?" It's incredibly difficult to do that when the prevailing development culture tells you that this pattern you're following is good, and the more of it you use then the better.
What I like to do is think about these decisions in terms of pain. It means asking a couple of questions before doing something:
- What pain am I going to avoid by doing this?
- What pain am I going to incur by doing this?
Now, if it's something like, "should I write some damn unit tests first?" the answer will come pretty easily - I avoid the pain that things are going to break unexpectedly on every seemingly innocuous refactor, and usually a good, lean TDD approach speeds up my development rather than slows it. Avoid lots of pain, incur none. Result: do the thing.
When it comes to a question like, "should I split up this class?" it becomes more interesting. Maybe it's a sprawling mess of junk, and while I'll incur a lot of pain to refactor, I'll also have to avoid the future pain of digging around in 1000 lines of code that just about everything else in the application touches. But if it's only 50 readable lines, I might conclude that my pain avoided would be merely, "nagging doubt I'm not doing SRP as everyone likes to understand it" while my pain incurred is, "having to open half a dozen files every time I want to look at how this works or change something". Result: don the cowboy hat, let's ship on time instead!
In other words, test and refactor on pain points. When something looks like it's going to become painful, have a look at it, figure out if there's a way to solve that pain which causes fewer problems overall, and if so do that. Generally you'll find that 2 or 3 design patterns emerge naturally (this is after all the point of design patterns - to describe what you've created using a common language) and good delineations of responsibility will grow organically.
Similarly, by doing this you'll end up writing tests mostly in response to pain, and cover the annoying bits that are worth testing (i.e. the bits which break unexpectedly when you refactor). Also, because you're letting things stay as coherent units until there's a reason to split them you can actually do proper unit testing rather than just write class tests. (One of my bugbears with over-zealous SRP is the zillions of tests, mapped 1:1 with tiny classes, that mostly test their own mock objects.)
The ultimate goal of writing a piece of code is to solve a problem. Code is good if it solves that problem reliably while being easy to maintain.
This is the definition to keep in mind, as it's the one which remains constant over time. As I say, strict TDD/SOLID is tipping into that cargo-cultish "bad code created with good intentions" territory. 10-15 years ago factories for everything and no tests was considered "good code". Go back another 10-15 years and terse, heavily-optimised assembler was the order of the day - I expect if you stuck some of that in a typical application nowadays the reaction would be horror at the lack of readability rather than admiration for the low CPU cycle count!
Don't get caught up writing the modern equivalent of factories. Keep things simple, refactor on pain, ship code and solve problems.