“Don’t call us, we’ll call you”
Over the past half year, I’ve had the SOLID principles of Object-oriented programming (OOP) drilled into my brain as I worked on honing my overall design sense in the quest to achieve software enlightenment.
One design pattern that I’ve always been favorable towards is Dependency Injection. In OOP, Dependency Injection (DI) allows us to pass an object into our functions in order to handle certain behavior. When the object that we would “inject” conforms to a particular interface that our function is expecting, we’re free to swap out this instance and inject something else (assuming that it shares the same interface) with slightly different behavior. The primary benefit that DI provides is the ability for the developer to extend the behavior of whatever is being injected without having to change the client code. My favorite part about dependency-injected functions is that they allow the developer to unit test a function that likely has side-effects by mocking the object being injected.
Since I started moving away from the world of Object-orientation and more towards Functional programming, I’ve constantly been looking for ways to improve my design skills in a functional context. Although there aren’t nearly as many neatly-defined patterns and principles in Functional programming as there are in OOP, I’ve found that there are a few general concepts that are somewhat transferrable. One of these concepts is analogous to Dependency Injection in Object-oriented programming.
To demonstrate this concept in a functional context, imagine that we’re creating a simple “flashcards” app that allows us to load up questions and answers from a CSV and quiz ourselves on our favorite topics. When thinking about the design of this application, the first thing we might do is create a
Round type for the higher-level modules to utilize.
In a statically-typed functional programming language, it’s common to pattern match on the different constructors of a given type as a way to identify what logical branch your code should take. The same way that a compiler in an OO language will throw an error if you have a class that hasn’t defined every method of the interface that it is implementing, the compiler in a functional language has to ensure that all
case statements* have a branch for every constructor of whatever type that is passed into it. If you’re coming from an OO background, this might not sound terribly intimidating, but in a statically-typed functional programming language like Elm it is common to take advantage of algebraic data types that offer multiple constructors because they give the developer greater flexibility when modeling their domain.
Therefore, every time we wanted to add a new value constructor for our
Round type, we would be forced to update all functions that pattern-match against the existing
Round constructors. This can quickly become unmaintainable as our application begins to scale:
So how can we change this? In an ideal world, we’d like to be able to treat anything with a
Round type the same way, but it seems like there are a few different branches that our code can take depending on the type of
Round constructor that we’re dealing with. Looking at these different constructors, it seems as if we might be able to categorize them into one of two groups: a round that is currently in progress and a round that is not currently in progress.
Even though we can divide these 4 constructors into 2 groups, we still don’t want to have to go into our code and change our
updateRound function every time a new
Round constructor is introduced, even if it does fall neatly into one of our categories. It would make more sense if we left it up to some function within the
Round module to decide which category each constructor falls into. That way, we can just supply a pair of operations to a function within the
Round module and let this function make the decision of “does this constructor qualify as a round in progress or not?”. Here’s what that function might look like inside the
Now we can change the structure of our
updateRound function doesn’t have to consider the different constructors in
Round and how they operate. As long as the constructors fall into one of the two abstractions that we’ve defined, all
updateRound has to do is provide the functions to
Round.either. With this change in place, we’re free to add as many constructors to
Round as we’d like without having to make any changes to
This concept can be referred to as the “Hollywood principle”.
“Don’t call us, we’ll call you.”
In other words, the
Round.either function is saying, “Don’t worry about what particular constructor this
round argument is, just hand over an
onInProgress function and an
onStopped function and I’ll take care of calling the correct one.”
Although the code for this was simple to write, the Hollywood principle is a powerful design pattern that puts the control back into the hands of the caller (
updateRound in this case) when it really matters. When developers write functions that have the possibility of failure, or even an alternate control flow path it doesn’t know how to take, it’s common practice to throw an exception as if to say, “I don’t know how to handle this scenario, so I’m going to let the end user know that we have a problem.”
On the other hand, by supplying a function that accounts for the possibility of an alternate scenario, we’re giving the caller ultimate control over what happens when things don’t go as expected.
To look at this pattern another way, imagine that you’re a supervisor at a small company and you directly oversee one intern at a time. Suppose that one day you get an intern that is very autonomous. Whenever your intern gets in a bind, they fall back on their instincts with an off-the-cuff decision. Whenever they come to a fork in the road, they don’t even bother to interrupt your busy day. As a supervisor who has seen a lot of incompetent interns, you actually appreciate this behavior. It’s nice to work with someone that isn’t afraid to pull the trigger. Sure, every few months you get chewed out by your boss for a wrong decision that your intern unabashedly made, but the cost of a small error on your intern’s behalf far outweighs the time it would take for you to constantly drop what you were doing every time something went slightly awry. Suppose that after a few months your company gets a round of funding and the executives in your company make the decision to expand hiring. Since your division has seen great productivity boosts as a result of hiring this intern, they decide to hire a bunch of similar interns for you to manage. However as time goes by, you find that the more interns you had, the more mistakes were made; and as a supervisor you’re ultimately responsible for these mistakes. Instead of the help that these interns were hired to provide, they eventually became liabilities.
Depending too heavily on autonomous interns is the same thing as relying too heavily on the functions your program interacts with . Sure, it might be nice at first not to be bothered with having to come up with a “what if” scenario every time there’s a possibility of failure, but your program will quickly become impossible to control as your program’s interactions with these functions multiply. By letting these dependencies go unchecked, you might soon find yourself in a position where you’re forfeiting the flow of control within your application every time you called a function that had a chance of failing.
As your higher-level code interacts with different functions across your application, it will become obvious which functions have a possibility of forking off into a separate logical branch, or failing outright. Recognizing these functions and extracting the resulting behavior to the level of the caller is something that might be best applied retroactively as a refactor. Once you start feeling the pain of trying to manage 3–4 loose cannons, then it’s time to start providing your interns with a concrete backup plan.
*Different statically-typed functional programming languages have their own syntax for pattern-matching. In Elm, it’s
case statements. In Haskell, it’s multiple function definitions. Despite these differences, the concept is still the same.