Abstraction in Elm: Domain-Based Modularization
Table of Contents
- Introduction
- One Expression to Rule Them All
- Introduce Comments
- Introduce Local Functions
- Utility-Based Modularization
- Domain-Based Modularization
- Wrappers and Generics for Everything
- Conclusion
Code
Did you think that we got rid of all the duplication when we introduced local functions? In fact, I intentionally kept a light touch on that first pass. There is still much more we could do.
In the previous section, we took the functions that were similar in their utility and grouped them into modules, hence the title of “utility-based modularization.” Utility, in this sense, means that we grouped functions together because they operated on similar data structures (RunStatus
) or because they described a particular runtime behavior (ColorOscillation
).
There is another kind partitioning that can be useful, and it can help us tease out some of the more subtle forms of duplication: abstraction based on domain.
Discussion
In the domain of returning a value from the update
function, we need to wrap our model into a tuple with a Cmd
. The And
module abstracts this behavior and will attract other kinds of Cmd
s that our system needs to generate (one can imagine And.execute
for simple port functionality or And.doSomeOtherBehavior
for more complex situations.
In the domain of making changes to the model, we have four actions in this system, and those seem to belong to two categories. One pair of actions is concerned with making changes to runStatus
, while the other is concerned with initiating animation effects. I partitioned these domains by introducing two new modules: Model.RunStatus
and Model.Animation
.
On the one hand, this kind of domain-driven partitioning can make the high levels of code extremely descriptive:
model
|> Model.RunStatus.setToGeneratingTests
|> Model.Animation.initiateColorOscillation
|> And.doNothing
This reads wonderfully: “Take the model, set the run status to ‘generating tests’, then initiate a color oscillation animation, and then complete the update with no additional commands.”
The other side of this coin is that the specific behavior of this code is more obscure than ever. What do any of these lines of code really do? In order to go find that out, I’m now going to have to examine five other files, and additionally, I’ll have to keep the relationships of those five files to each other and to Main.elm in my head as I go.
Now, if my system is comprised of thirty message constructors (as Elm Test Runner happens to be at the time of this writing), then this kind of structure can be worth its weight, precisely for that supposed drawback. Think about how you go about understanding a large and complex system. When I first approach such a system, I don’t want to have to figure out how the entire application works in order to make a change to color animations! I just want to understand the color animation sub-system. Even though these pithy lines in main obscure the details, well-crafted partitions can make very large projects easier to comprehend, because they delineate sub-systems, allowing the engineer to learn those sub-systems one at a time.
This situation is much preferable to a large, low-abstraction software system, in which a sea of details must be waded and sifted for the bits relevant to the sub-system one wants to learn about. In my experience, large, low-abstraction projects are much more difficult to read, comprehend, and make changes to, than are large, high-abstraction projects, but only if the partitions delineate the sub-systems rather than slicing them to pieces.
Engineers coming to Elm from React, Angular, and Vue will do well to let go of DOM-based components as a default abstraction mechanism, and instead consider the various ways they could partition based on utility and domain in their Elm applications.
At the same time, the heavy level of abstraction described in this section must be used judiciously and is most useful when a system is so large that no normal human being is ever going to attempt to hold the whole thing in mind at a given moment.
Analysis
Pros
- If we’ve done a good job with our names, then the highest levels of our application (
Main
) read extraordinarily well. - It is clearer than ever why the system was constructed the way it was. Intent is approaching crystal clarity.
Cons
- The details of our system, what it specifically does, have become hidden in an expanse of lower-level files.
- Multiple modules must be traversed to get from
main
to the specific behavior, and an engineer attempting to decipher the system must hold all of those module relationships in their head in order to figure out what’s going on. - Interestingly, we have begun, once again, to obscure the relationships between various parts of the code. Domain-based partitions cause engineers to have to do extra work in order to form a mental picture of system cohesion.
- High-level readability has also come at the expense of ease-of-reasoning about the system as a whole. (This is mitigated somewhat if we have a very large system. Since it’s impossible for a human being to keep the whole system in mind anyway, having partitions that delineate the sub-systems makes it easier to learn whichever sub-system an engineer is interested in at a given time.)
Continue to Abstraction in Elm: Wrappers and Generics for Everything.