3.2 CRUST Considerations
We’re getting closer to function internals, which will be discussed at length in chapter 4. Before we do so, we need to address a few more concerns on the component level. This section explores how we can keep components simple by following the CRUST principle outlined in chapter 2.
3.2.1 Do Repeat Yourself, Occasionally
The DRY principle (Don’t Repeat Yourself) is one of the best regarded principles in software development, and rightly so. It prompts us to write a loop when we could write a hundred print statements, it makes us create reusable functions so that we don’t end up having to maintain several instances of the same piece of code, and it questions the need for slight permutations of what’s virtually the same piece of code repeated over and over across our codebases.
When taken to the extreme, though, DRY is harmful and hinders development. Our mission to find the right abstractions will be cut short if we are ever vigilant in our quest to suppress any and all repetition. When it comes to finding abstractions, it’s almost always best to pause and reflect on whether we ought to force DRY at this moment, or if we should wait a while and see whether a better pattern emerges.
Being too quick to follow DRY may result in picking the wrong abstraction, costing us time if we realize the mistake early enough, and causing even more damage the longer we let an undesirable abstraction loose.
In a similar fashion, blindly following DRY for even the smallest bit of code is bound to make our code harder to follow or read. Merging two sides of a regular expression that was optimized for readability (a rare sight in the world of regular expressions) will almost certainly make it harder to read and correctly infer its purpose. Is following DRY truly worthwhile in cases like this?
The whole point of DRY is to write concise code, improving readability in turn. When the more concise piece of code results in a program that’s harder to read than what we had, DRY was probably a bad idea, a solution to a problem we didn’t yet have, not in this particular piece of code, not yet anyway. In order to stay sane, it’s necessary to take software development advice with a grain of salt, as we’ll discuss in section 3.3.4.
Most often, DRY is the correct approach, but there are indeed cases when DRY might not be appropriate, such as when it yields trivial gains at the expense of readability or when it hinders our ability to find better abstractions. We can always come back to our piece of code and sculpt pieces away making it more DRY. This is typically easier than trying to decouple bits of code we’ve mistakenly made DRY, which is why sometimes it’s best to wait before we commit to DRY.
3.2.2 Feature Isolation
We’ve discussed interface design at great length, but we haven’t touched on decisions around when to split a module into smaller pieces. In modern application architectures, having certain modules may be required by conventional practices. For instance, a web application made up of different views may require that each view is its own component. This limitation shouldn’t, however, stop us from breaking up the internal implementation of the view into several smaller components. These smaller components might be reused in other views or components, tested on their own, and better isolated than they might have otherwise been if they were tightly coupled to their parent view.
Even when the smaller component isn’t being reused anywhere else, and perhaps not even tested on its own, it’s still worth moving it to a different file. Why? Because we’re removing the complexity that makes up the child component from its parent virtually for free. We’re only paying a cheap indirection cost, where the child component is now referenced as a dependency of its parent instead of being inlined. When we split up the internals of a large component into several children, we’re chopping up its internal complexity and ending up with several simple components. The complexity didn’t dissipate, it’s subtly hidden away in the interrelationships between these child components and their parent, but that’s now the biggest concern in the parent module, whereas each of the smaller modules doesn’t need to know much about these relationships.
Chopping up internals doesn’t merely only work for view components and their children. That said, view components pose a great example that might help us visualize how complexity can remain flat across a component system, regardless of how deep we go, instead of being contained in a large component with little structure and a high-level of complexity or coupling. This is akin to looking at the universe on a macroscopic level and then taking a closer look, until we get to the atomic level, and then beyond. Each layer has its own complexities and intricacies waiting to be discovered, but the complexity is spread across the layers rather than clustered on any one particular layer. The spread reduces the amount of complexity we have to observe and deal with on any given layer.
Speaking of layers, it is at this stage of the design process that you might want to consider defining different layers for your application. You might be used to having models, views, and controllers in MVC applications, or maybe you’re accustomed to actions, reducers, and selectors in Redux applications. Maybe you should think of implementing a service layer where all the business logic occurs, or perhaps a persistance layer where all the caching and persistent storage takes place.
When we’re not dealing with modules which we ought to shape in a certain way, like views, but modules that can be composed any which way we choose, like services, we should consider whether new features belong in an existing module or in an entirely new module. When we have a module which wraps a Markdown parsing library adding functionality such as support for emoji expansions, and want an API that can take the resulting HTML and strip out certain tags and attributes, should we add that functionality to the Markdown module or put it in a separate module?
On the one hand, having it in the Markdown module would save us the trouble of importing both modules when we want the sanitization functionality, but on the other hand, there may be quite a few cases where we have HTML that didn’t come from Markdown parsing but which we still want to sanitize. A solution that’s often effective in these cases is putting the HTML sanitization functionality into its own module, but consume it in the Markdown module for convenience. This way, consumers of the Markdown module always get sanitized output, and those who want to sanitize a piece of HTML directly can do so as well. We could always make sanitization opt-in (or better yet, opt-out) for the Markdown module, if the feature isn’t always what’s needed by consumers of that interface.
It can be tempting to create a utilities.js
module where we deposit all of our functionality which doesn’t belong anywhere else. When we move onto a new project, we tend to want some of this functionality once again, so we might copy the relevant parts over to the new module. Here we’d be breaking the DRY principle, because instead of reusing the same bits of code we’re creating a new module that’s a duplicate of what we had. Worse yet, over time we’ll eventually modify the utilities.js
component, so they might not contain the same functionality anymore.
The low hanging fruit here would be to create a lib
directory instead of a single utilities.js
module, and place each independent piece of functionality into its own module. Naturally, some of these pieces of functionality will depend on other utility functions, but we’ll be better off importing those bits from another module than keeping everything in the same file. Each small file makes it obvious what the utility is, what other bits it relies on, and can be tested and documented individually. More importantly, when the utility grows in scope, file size, and complexity, it will remain manageable because we’ve isolated it early. In contrast, if we kept everything in the same file but then one of the utilities grew considerably, we’d have to pull the functionality into a different module, at which point our code might be coupled with other utilities in subtle ways that might make the migration to a multi-module architecture a bit harder than it should be.
Were we to truly embrace a modular architecture, we might go an extra mile after promoting each utility to its own module. Aftering identifying utility modules we’d like to reuse — such as a function used to generate slugs like this-is-a-slug
based on an arbitrary string that might have spaces, accents, punctuation, and symbols, besides alphanumeric characters — we could move the module to its own directory, along with documentation and tests, register any dependencies in package.json
, and publish it to an npm registry. In doing so, we’d be honoring DRY across projects, and when we update the slugging package while working on our latest project, older projects would also benefit from new functionality and bug fixes.
This approach can be taken as far as we consider necessary: as long as we’d benefit from making a piece of functionality reusable across our projects, we can make it reusable, adding tests and documentation along the way. Note that hypermodularity offers diminishing returns, the more we take modularity to the extreme, the more time we’ll have to spend on documentation and testing. If we intend to release each line of code we develop as its own well-documented and well-tested package, we’ll be spending quite some time on tasks that are not directly related to developing features or fixing bugs. As always, use your own judgement to decide how far to take modular structures.
When a piece of code is not very complex and rather small, it’s usually not worth creating a module for. It might be better kept in a function on the module where it’s consumed, or inlined every time. Such short pieces of code tend to change and branch out, often necessitating slightly different implementations in different portions of our codebase. Given the amount of code is so small, it’s hardly worth our time to figure out a way to generalize the snippet of code for all or even most use cases. Chances are we’d end up with something more complex than if we just inlined the functionality to begin with.
When a piece of code involves enough complexity to warrant its own module, that doesn’t immediately make it worthwhile to create a package for it. External modules often involve a little bit more of maintenance work, in exchange for being reusable across codebases and offering a cleanlier interface that’s properly documented. Take into consideration the amount of time you’ll have to spend on extricating the module and on writing documentation, and whether that’s worth the effort. Extricating the module will be challenging if it has dependencies on other parts of the codebase it belongs to, since those would have to be extricated as well. Writing documentation is typically not something we do for every module of a codebase, but we have to document modules when they’re their own package, since we can’t expect other potential consumers to effectively decide whether they’ll be using a package without having read exactly what it does or how to use it.
3.2.3 Trade-offs when Designing Internals
When we’re designing the internals of a module, it’s key to keep our priorities in order: the goal is to do what consumers of this module need. That goal has several aspects to it, so let’s visit them in order of importance.
First off, we need to design the right interface. A complicated interface will frustrate and drive off consumers, making our module irrelevant or, at best, a pain to work with. Having an elegant or fast implementation will be of little help if our reluctant consumers have trouble leveraging the interface in front of them. A programming interface is so much more than beautiful packaging making up for a mediocre present. For consumers, the interface should be all there is. Having a simple, concise, and intuitive interface will, in turn, drive down complexity in code written by consumers. Thus, the number one aspect to our goal is to find the best possible interface that caters to the needs and wants of its consumers.
Second, we need to develop something that works precisely as advertised and documented. An elegant and fast implementation that doesn’t do what it’s supposed to is no good to our consumers. Promising the right interface is great, but it needs to be backed up by an implementation that can deliver on the promises we make through the interface. Only then can consumers trust the code we write.
Third, the implementation should be as simple as possible. The simpler our code is, the easier it will be for us to introduce changes to it without having to rewrite the existing implementation. Note that simple doesn’t necessarily mean terse. For example, a simple implementation might indulge in long but descriptive variable names and a few comments explaining why code is written the way it is. Besides the ability to introduce changes, simple code is easier to follow when debugging errors, when new developers interact with the piece of software, or when the original implementors need to interact with it after a long period of time without having to worry about it. implementation simplicity comes in third, but only after a proper interface that works as expected.
Fourth, the internals should be as performant as possible. Granted, some measure of performance is codified in producing something that works well, as something that’s too slow to be considered reliable would be unacceptable to consumers. Beyond that, performance falls to the fourth place in our list of desirable traits. Performance is a feature, to be treated as such, and we should favor simplicity and readability over speed. There are exceptions where performance is of the utmost importance, even at the cost of producing suboptimal interfaces and code that’s not all that easy to read, but in these cases we should at least strive to heavily comment the relevant pieces of code so that it’s abundantly clear why the code had to be written the way it was.
Flexibility, other than that afforded by writing simple code and providing an appropriate interface, has no place in satisfying the needs of our consumers. Trying to anticipate needs is more often than not going to result in more complexity, code, and time spent, with hardly anything to show for in terms of improving the consumer’s experience.