There is, in software development, a tension between external pressures to turn out new features and products, and the internal need to maintain high professional standards and minimize technical debt. It can be hard, from the outside, to see the business value in things like refactoring, architectural planning, high standards for code design, and the like, if they seem to extend the development timeline for some new, important piece of software. Some, like code design, might seem purely a matter of aesthetics, if it all leads to the same place, in the end. This perspective is short-sighted, and is quite often fatal or at least highly detrimental to whatever business interests were supposedly being served by undervaluing engineering professionalism.
You might be thinking: “Well, maybe code design is ultimately a subjective thing, like those godless heathens who use tabs to indent their code, or maybe only perfectionists who are impossible to please should, or do, care about it.” While opinions may vary on what is the best design, neither objection is warranted, and good code design objectively has numerous advantages, some of which are plainly evident, some of which take experience to see, and some of which only become apparent days, months, or years down the line.
For instance, well-designed code is easy to read and understand. It is also easily tested, and with experience an engineer will learn that the reverse tends to be true as well: code that is hard to test is not well-designed. The big one, though, that dwarfs all the others, is that well-designed code makes it easy to change the behavior of the code without changing the source code itself.
How is it possible to change the behavior of the code without changing the code itself? I’ll give an example. Let’s say we have a class, and in that class we want to log certain things to the console. A naïve implementation might be something like (in pseudocode:)
MyClass {
myFunc {
Logger.debug('myFunc called')
}
otherFunc {
Logger.debug('otherFunc called')
}
}
This code is hard to change, because if we ever want to log things in a different way, we have to change it in every single location where we use Logger
. Instead, this is a good use case for the Strategy pattern:
MyClass(logger) {
myFunc {
@logger.debug('myFunc called')
}
}
That doesn’t immediately look like an improvement, as the actual code sprinkled throughout the class would be almost identical. The magic is when we want to actually use the class. We pass in whatever logger we want to use, as an initializer argument:
ConsoleLogger {}
my_obj = MyClass(ConsoleLogger())
Loggers have a small, well-defined interface, so it’s very easy to imagine a FancyLogger
being added that adds colors to the output, and some time later a WebLogger
that sends the messages to a web service, bypassing the console entirely. By using the class with different collaborators, the behavior of the code is modified without having to change it.
Of course, some code in your app might be changing, but in this case that’s just the argument that you pass to your class, not the class itself. That, actually, might not change, either—you might just use MyClass
somewhere else in your app, with a different logger, without having to worry about changing the class to support a new use case. Or, more likely, you might use one logger during testing and use another in production.
This pattern—Strategy—is one of the most useful, and widely used, design patterns, ever. It comes from the book known simply as the Gang of Four, from the four authors:
This book is a classic of software engineering and is probably the closest thing to a holy book we have, as a profession. It is college textbook-sized, and absolutely jam-packed with patterns that make it easy to change code behavior without changing the source code (among other design goals.)
This is not the Strategy pattern:
ConsoleLogger {}
MyLogger {
debug(message) {
ConsoleLogger().debug(message)
}
}
MyClass {
myFunc {
MyLogger().debug(message)
}
}
You might say, “well, now if we want to change the logging code we just have to do it in one place. That’s the same thing, isn’t it?” Indeed, there are patterns based around wrapping 3rd party code so you don’t have to change your code to use a new, complex library. This is not one of them.
Let’s try another example to illustrate the problem with just using a wrapper. Let’s say we have some class that uses a web service to do something. It could be anything, from sending an SMS, to looking up movie times. The actual function isn’t important.
WebService {}
MyWebService {
call {
WebService.call()
}
}
MyClass {
myFunc {
MyWebService().call()
}
}
Same situation: we’ve centralized where we access the third party code, but everything is still, ultimately, hardcoded. Still, if we ever want to swap out WebService
, we only have to change it inside MyWebService
. Now, let’s suppose we use this in two places, and let’s also suppose the two use cases are pretty different: one handles many small requests, and the other handles a few big requests.
lots_of_small_requests = MyClass()
few_large_requests = MyClass()
However, circumstances change, and soon WebServiceCo has a competitor: OtherWebServiceCo. WebServiceCo is cost-effective for a lot of small requests, but charges a ton more for large requests. Conversely, OtherWebServiceCo doesn’t charge anything for request size, but charges a fairly high fixed fee per request. We want to use whichever service is most cost effective for a given use case, but we’re trapped: there’s only one MyClass
being used in both places, and both have to use MyWebService
because it is hard coded into MyClass
, even though that is behavior that we want to—and which should—vary independently. We’re stuck making some pretty significant source codes change, despite the use of an abstraction.
We're starting to see that wrapping a library or framework class isn’t actually, by itself, a design pattern, or great code design. Inappropriate and needless abstractions might seem valuable to the novice, but they significantly increase the carrying cost of the code, the amount of programmer time to read, understand, troubleshoot, and modify the code, the number of tests (if we’re really unlucky, and some poor sod has actually spent time writing tests for them) while simultaneously making the code less useful, less flexible, and less changeable in the future.
From the outsider's perspective, you'll experience this as a sense of bewilderment, when what should have been a simple change (use OtherWebServiceCo here, but not there) takes significantly longer than it would, otherwise. This will get worse over time, especially if the bad design is compounded by correspondingly increased time pressure. There is no design so bad that a quick and dirty hack can't be conjured that would both fix the immediate problem and make things worse at the same time.