3.3 Pruning a Module
Much like modern web development, module design is never truly done. In this section we’ll visit a few discussion topics that’ll get you thinking about the long half-life of components, and how we can design and build our components so that they don’t cause us much trouble after we’ve finished actively developing them.
3.3.1 Error Handling, Mitigation, Detection, and Solving
While working on software development we’ll invariably need to spend time analyzing the root cause that led to subtle bugs which seem impossible to hunt down. Only after spending invaluable time we will figure out it was caused by a small difference in program state than what we had taken for granted, and that small difference snowballed through our application’s logic flow into the serious issue we just had to hunt down.
We can’t prevent this from happening over and over — not entirely. Unexpected bugs will always find their way to the surface. Maybe we don’t control a piece of software which interacts with our own code in an unexpected way, which works well until it doesn’t anymore because of a problem in the data. Maybe the problem is merely that a validation function isn’t working the way it’s supposed to, allowing some data to flow through the system in a shape that it shouldn’t, but by the time it causes an error we’ll spend quite some time until we figure out that indeed, the culprit is a bug in our validation function, triggered by a malformed kind of input that was undertested. Since the bug is completely unrelated to the error’s stack track information, we might spend a few hours hunting down and identifying the issue.
What we can do is mitigate the risk of bugs by writing more predictable code or improving test coverage. We can also become more proficient at debugging.
On the predictable code arena, we must be sure to handle every expected error. When it comes to error handling we typically will bubble the error up the stack and handle it at the top, by logging it to an analytics tracker, to standard output, or to a database. When using a function call we know might throw, like JSON.parse
on user input, we should wrap it with try
/catch
and handle the error, again bubbling it up to the consumer if our inability to proceed with the function logic is final. If we’re dealing with conventional callbacks that have an error argument, let’s handle the error in a guard clause. Whenever we have a promise chain, make sure to add a .catch
reaction to the end of the chain that handles any errors occurring in the chain. In the case of async
functions, we could use try
/catch
or, alternatively, we can also add a .catch
reaction to the result of invoking the async function. While leveraging streams or other conventional event-based interfaces, make sure to bind an error
event handler. Proper error handling should all but eliminate the chance of expected errors crippling our software. Simple code is predictable. Thus, following the suggestions in chapter 4 will aid us in reducing the odds of encountering unexpected errors as well.
Test coverage can help detect unexpected errors. If we have simple and predictable code, it’s harder for unexpected errors to seep through the seams. Tests can further abridge the gap by enlarging the corpus of expected errors. When we add tests, preventable errors are codified by test cases and fixtures. When tests are comprehensive enough, we might run into unexpected errors in testing and fix them. Since we’ve already codified them in a test case, these errors can’t happen again (a test regression) without our test suite failing.
Regardless of how determined we are to develop simple, predictable, and thoroughly tested programs, we’re still bound to run into bugs we hadn’t expected. Tests exist mostly to prevent regressions, preventing us from running once again into bugs we’ve already fixed; and to prevent expected mistakes, errors we think might arise if we were to tweak our code in incorrect ways. Tests can do little to prognosticate and prevent software bugs from happening, however.
This brings us to the inevitability of debugging. Using step-through debugging and inspecting application state as we step through the code leading to a bug is an useful tool, but it will not help us debug our code any faster than we can diagnose exactly what is going on.
In order to become truly effective debuggers, we must understand how the software we depend on works internally. If we don’t understand the internals of something, we’re effectively dealing with a black box where anything can happen from our perspective. This adventure is left as an exercise to the reader, who is better equipped to determine how to obtain a higher understanding of how their dependencies truly work. It might be the case that reading the documentation will suffice, but note that this is rarely the case. Perhaps you should opt to download the source code from GitHub and give it a read. Maybe you’re more of a hands-on kind of person and prefer to try your hand at making your own knock-off of a library you depend on, in order to understand how it works. Regardless of the path you take, the next time you run into an expected error related to a dependency you’re more intimately familiar with, you’ll have less of a hard time identifying the root cause, since you’ll be aware of the limitations and common pitfalls of what was previously mostly a black box to you. Documentation can only take us so far in understanding how something works behind the hood, which is what’s required when tracking down unexpected errors.
3.3.2 Documentation as an Art
It is true, in the hard times of tracking down and fixing an unexpected error, documentation often plays a diminished role. Documentation is, however, often fundamental when trying to understand how a piece of code works, and this can’t be underestimated. Public interface documentation underscores readable code, providing not only a guide for consumers to draw from for usage examples and advanced configuration options that may aid them when coming up with their own designs, but is also useful for implementers as a reference of exactly what consumers are promised and, hence, ultimately expect.
In this section we’re talking about documentation in its broadest possible sense. We’ve discussed public interface documentation, but tests and code comments are also documentation in their own way. Even variable or function names should be considered a kind of documentation. Tests act as programmatic documentation for the kinds of inputs and outputs we expect from our public interfaces. In the case of integration tests, they describe the minimum acceptable behavior of our application, such as allowing users to log in providing an email and a password. Code comments serve as documentation for implementers to understand why code looks the way it does, areas of improvement, and often refer the reader to links that offer further details on a bug fix that might not look all that elegant at first sight. Descriptive variable names can, cumulatively, save the reader considerable time when explicit names like products
are preferred over vague and ambiguous names like data
. The same applies to function names, where we should prefer names like aggregateSessionsPerDay
over something shorter but unclear such as getStats
.
Getting into the habit of treating every bit of code and the structure around it (formal documentation, tests, comments) as documentation itself is only logical. Those who will be reading our code in the future — developers looking to further their understanding of how the code works, and implementers doing the same in order to extend or repair a portion of functionality — rely on our ability to convey a concise message on how the interface and its internals work.
Why would we not, then, strive to take advantage of every variable, property, and function name; every component name, every test case, and every bit of formal documentation, to explain precisely what our programs do, how they do it, and why we went for the trade-offs we took?
In this sense, we should consider documentation to be the art of taking every possible opportunity to clearly and deliberately express the intent and reasoning of all of the different aspects of our modules.
The above doesn’t mean to say we should flood consumers and implementers alike until they drown in a tumultuous stream of neverending documentation. On the contrary, only by being deliberate in our messaging can be strike the right balance and describe the public interface in formal documentation, describe notable usage examples in our test cases, and explain abnormalities in comments.
Following a holistic approach to documentation, where we’re aware of who might be reading what and what should be directed to whom, should result in easy-to-follow prose that’s not ambiguous as to usage or best practices, nor fragmented, nor repetitive. Interface documentation should be limited to its usage, and is rarely the place to discuss design choices, which can be relayed to architecture or design documentation, and later linked in relevant places. Comments are great for explaining why, or linking to a bug fixed in their vicinity, but they aren’t usually the best place to discuss why an interface looks the way it does, and this is better left to architecture documentation or our issue tracker of choice. Dead code should definitely not be kept around in comment blocks, as it does nothing but confuse the reader, and is better kept in feature branches or git stashes, but off the trunk of source control.
Tom Preston-Werner wrote about the notion of README-driven development as a way of designing an interface by first describing it in terms of how it would get used. This is generally more effective than test-driven design (TDD), where we’ll often find ourselves rewriting the same bits of code over and over before we realize we wanted to produce a different API to begin with. The way README-driven design is supposed to work is self-descriptive: we begin by creating a README file and writing our interface’s documentation. We can start with the most common use cases, inputs and desired outputs, as described in section 2.1.2, and grow our interface from there. Doing this in a README file instead of a module leaves us an itsy bit more detached from an eventual implementation, but the essence is the same. The largest difference is that, much like TDD, we’d be committing to writing a README file over and over before we settle for a desirable API. Regardless, both API-first and README-driven design offer significant advantages over diving straight to an implementation.
3.3.3 Removing Code
There’s a popular phrase in the world of CSS about how it’s an "append-only language" implicating that once a piece of CSS code has been added it can’t be removed any longer, because doing so could inadvertently break our designs, due to the way the cascade works. JavaScript doesn’t make it quite that hard to remove code, but it’s indeed a highly dynamic language, and removing code with the certainty that nothing will break remains a bit of a challenge as well.
Naturally, it’s easier to modify a module’s internal implementation than to change its public API, as the effects of doing so would be limited to the module’s internals. Internal changes that don’t affect the API are typically not observable from the outside. The exception to that rule would be when consumers monkey-patch[2] our interface, sometimes becoming able to observe some of our internals. In this case, however, the consumer should be aware of how brittle monkey-patching a module they do not control is, and they did so assuming the risk of breakage.
In section 3.1.2 we observed that the best code is no code at all, and this has implications when it comes to removing code as well. Code we never write is code we don’t need to worry about deleting. The less code there is, the less code we need to maintain, the less potential bugs we are yet to uncover, and the less code we need to read, test, and deliver over mobile networks to speed-hungry humans.
As portions of our programs become stale and unused, it is best to remove them entirely instead of postponing their inevitable fate. Any code we desire to keep around for reference or the possibility of reinstating it in the future can be safely preserved by source control software without the necessity of keeping it around in our codebase. Avoiding commented out code and removing unused code as soon as possible will keep our codebase cleaner and easy to follow. When there’s dead code, a developer might be uncertain as to whether this is actually somehow being in use somewhere else, and reluctant to remove it. As time passes, the theory of broken windows comes into full effect and we’ll soon have a codebase that’s riddled with unused code nobody knows why it’s there or how it is that the codebase has come to be so unmanageable.
Reusability plays a role in code removal, as more components depend on a module, it becomes more unlikely we’ll be able to trivially remove the heavily depended-on piece of code. When a module has no connections to other modules, it can be removed from the codebase, but might still serve a purpose as its own standalone package.
3.3.4 Applying Context
Software development advice is often written in absolute terms, rarely considering context. When you bend a rule to fit your situation, you’re not necessarily disagreeing with the advice, you might just have applied a different context to the same problem. The adviser may have missed that context, or they might have avoided it as it was inconvenient.
However convincing an eloquent piece of advice or tool might seem, always apply your own critical thinking and context first. What might work for large companies at incredible scale, under great load, and with their own unique set of problems, might not be suitable for your personal blogging project. What might seem like a sensible idea for a weekend hack, might not be the best use of a mid-size startup’s time.
Whenever you’re analyzing whether a dependency, tool, or piece of advice fits your needs, always start by reading what there is to be read and consider whether the problem being solved is one you indeed need to solve. Avoid falling in the trap of leveraging advice or tools merely because it became popular or is being hailed by a large actor.
Never overcommit to that which you’re not certain fits your needs, but always experiment. It is by keeping an open mind that we can capture new knowledge, improve our understanding of the world, and innovate. This is aided by critical thinking and hindered by rushing to the newest technology without firsthand experimentation. In any case, rules are meant to be bent, and broken.
Let’s move into the next chapter, where we’ll decypher the art of writing less complex functions.
1. A/B testing is a form of user testing where we take a small portion of users and present them with a different experience than what we present to the general userbase. We then track engagement among the two groups, and if the engagement is higher for the users with the new experience, then we might go ahead and present that to our entire userbase. It is an effective way of reducing risk when we want to modify our user experience, by testing our assumptions in small experiments before we introduce changes to the majority of our users.
2. Monkey-patching is when we intentionally modify the public interface of a component from the outside in order to add, remove, or modify its functionality. Monkey-patching can be helpful when we want to change the behavior of a component we don’t control, such as a library or dependency. Patching is error-prone because we might be affecting other consumers of this API, who are unaware of our patches. The API itself or its internals may also change, breaking the assumptions made about them in our patch. While it’s generally best avoided, sometimes it’s the only choice at hand.