4.1 Internal Complexity
Every piece of code we write is a source of internal complexity, with the potential to become a large pain point for our codebase as a whole. That said, most bits of code are relatively harmless when compared to the entire corpus of our codebase, and trying to proof our code against complexity is a sure way of increasing complexity for no observable benefit. The question is, then, how do we identify the small problems before they grow into a serious threat to the maintainability of our project?
Making a conscious effort to track pieces of code that we haven’t changed or interacted with in a while, and identifying if they’re simple enough to understand can help us determine whether refactoring may be in order. We could perhaps set a rule whereby team members should watch out for garden paths in the codebase and fix them as they are making changes in the same functional area as the affected code. When we track complexity methodically, often, and across the entire team that’s responsible for a codebase, we can expect to see many small but cumulative gains in our battle against complexity.
4.1.1 Containing Nested Complexity
In JavaScript, deep nesting is one of the clearest signs of complexity. Understanding the code at any given nesting level involves understanding how the flow arrives there, the state at every level in scope, how the flow might break out of the level, and which other flows might lead to the same level. Granted, we don’t always need to keep all this derived information in our memory. The problem is that, when we do, we might have to spend quite a few minutes reading and understanding the code, deriving such information, and otherwise not fixing the bug or implementing the feature that we had set out to resolve in the first place.
Nesting is the underlying source of complexity in patterns such as "Callback Hell", or "Promise Hell", where callbacks are nested on top of one another. The complexity has little to do with spacing, although when taken to the extreme it does make code harder to read. Instead, the complexity exists at the seams, where we need to fully understand the context in order to go deep into the callback chain and make fixes or improvements. An insidious variant of callback hell is the one where we have logic in every nesting level. This variant is coincidentally the one we can observe most often in real applications: we rarely have callbacks as depicted in the bit of code below, partly because it’s immediately obvious that something is wrong. We should probably either change the API so that we get everything we need at once, or we could leverage a small library that takes care of the flow while eliminating the deep nesting we’d otherwise have in our own code.
- getProducts(products => {
- getProductPrices(products, prices => {
- getProductDetails({ products, prices }, details => {
- // …
- })
- })
- })
When we have synchronous logic intermixed with asynchronous callbacks, things get more challenging. The problem here is, almost always, a coupling of concerns. When a program has a series of nested callbacks that also include logic in between, it can be a sign that we’re mixing flow control concerns with business concerns. In other words, our program would be in a better place if we kept the flow separate from the business logic. By splitting the code that purely determines the flow from the rest, we can better isolate our logic into its individual components. The flow, in turn, also becomes more clear because it’s now spelled out in plain sight instead of interleaved with business concerns.
Suppose that each nesting level in a series of callbacks contains about 50 lines of code. Each function in the series needs to reference zero, one, or more variables in its parent scope. If it needs zero references to its immediate parent scope, we can safely move it up to the same scope as its parent. We can repeat this process until the function is in the highest possible level where it can, given the variables it has to reference. When functions reference at least one variable from the parent scope, we could opt to leave them unchanged or to pass those references as parameters so that we can keep on decoupling the functions.
As we move logic into their own functions and flatten the callback chain, we’ll be left with the bare flow of operations being separate from the operations themselves. Libraries like contra
can help manage the flow itself while user code worries about business logic.
4.1.2 Feature Entanglement and Tight Coupling
As a module becomes larger, it also gets easier to mistakenly collapse distinct features together by interleaving their code in such a way that it is hard to reuse each feature independently, debug and maintain them, or otherwise extricate the features from one another.
For example, if we have a feature for notifying subscribers and a feature to send notifications, we could strive to keep the features apart by clearly defining how notifications can be constructed and handed off to a different service which then sends those notifications. That way, subscriber notifications can be sent through the notification service, but given the clear separation we won’t be letting subscriber-specific notions to get in the way of sending other kinds of notifications to our customers.
One way of reducing the risk of entanglement would be to design features upfront, being particularly on the lookout about concerns that could be componentized or otherwise clearly delineated. By doing a little work before sitting down to write code, we might avert the risks of tight coupling.
Being alert when reading old code can also be key in identifying what was previously a well-contained module that evolved to cover a broad range of concerns. We can then, over time, break apart these concerns into individual modules or better-isolated functions so that each concern is easier to maintain and understand separately.
Instead of trying to build a large feature all at once, it could come in handy to build it from the inside out, keeping each stage of the process in functions that live at the same level instead of being deeply nested. Doing this methodically will lead to better decoupling, as we’ll move away from monolithic structures and towards a more modular approach, where functions have smaller scopes and take what they need in the form of parameters.
When we’d have to repeat ourselves by passing a lot of scope variables as function parameters just to avoid nested functions, a light degree of nesting is desirable to avoid this repetition. In key functional boundaries where our concerns go from "gather model details" to "render HTML page" to "print HTML page to PDF", nesting will invariably lead to coupling and less reusability, which is why repeating ourselves a little bit may be warranted in these cases.
4.1.3 Frameworks: the Good the Bad and the Ugly
Conventions are useful because they allow for better self-direction amongst developers, without causing lagoons of inconsistency to spread across our codebase as fate would have it were we to allow a team of developers too much freedom without sound design direction and conventions that dictate how different portions of an application should be shaped. A large number of conventions might hinder productivity, especially if some of our conventions appeared to work as if by magic.
When it comes to conventions, frameworks are a special case. Frameworks are packed to the brim with conventions and best practices. Some of them live in the library and tooling ecosystem around the framework, while many live in the shape our code takes when we rely on said framework. Upon adopting a framework, we’re buying into its conventions and practices. Most modern JavaScript frameworks offer ways of breaking our application into small chunks, regardless of whether the framework is for the client or server.
Express has middleware and routes, Angular has directives, services, and controllers, React has components, and so on and so forth. These conventions and abstractions are tremendously helpful to keep complexity in check while building an application. As our components grow larger, regardless of the abstraction or framework of choice, things will get more complicated. At this moment we usually can refactor our code into smaller components that are then wrapped with larger ones, preserving separation of concerns and keeping complexity on a short leash.
Eventually, we’ll come into requirements that don’t exactly fit the mold proposed by our framework of choice. Generally, this means the required functionality belongs on a separate layer. For example, Express in Node.js is a framework concerned with handling HTTP requests and serving responses. If one of our API endpoints needs to result in an email being sent, we could embed email-sending logic in the controller for that API endpoint. However, if an API endpoint controller is already concerned with, say, publishing blog posts, then it would be hardly right to embed email-sending logic on that same controller, since it’s a different concern entirely. Instead, what we could do is create a subscribers
service component, with functionality such as subscribe
which adds a subscriber after verifying their email, and notify
which takes care of sending the emails. Taking this idea further still, perhaps most of the work in subscribers.notify
should occur via yet another service component called emails
, which takes care of properly configuring our email sending capability, and also has functionality to turn would-be emails into plain console.log
statements for quick access to the contents of the emails during debug sessions.
Having clearly defined layers is paramount to the design of effective and maintainable applications once we’re past the prototyping stages. Layers can be made up of components which follow the conventions proposed by the frameworks we use, or they can be self-imposed like the service layer we discussed in the previous paragraph. Using layers, and as long as we favor function parameters over scope for context-passing, we can introduce horizontal scaling by placing several orthogonal components alongside each other, without letting them run into each others' concerns.