- Modularity
- Modules
- Modularity in Software Systems
- Modularity, Complexity, and Coupling
- Coupling in Modularity
- Key Takeaways
- Quiz
Modularity, Complexity, and Coupling
Poor design of a system’s modules leads to complexity. As we discussed in Chapter 3, complexity can be both local and global, while the exact meaning of local/global depends on point of view: Global complexity is local complexity at a higher level of abstraction, and vice versa. But what exactly makes one design modular and another one complex?
Both modularity and complexity result from how knowledge is shared across the system’s design. Sharing extraneous knowledge across components increases the cognitive load required to understand the system and introduces complex interactions (unintended results, or intended results but in unintended ways).
Modularity, on the other hand, controls complexity of a system in two ways:
Eliminating accidental complexity; in other words, avoiding complexity driven by the poor design of a system.
Managing the system’s essential complexity. The essential complexity is an inherent part of the system’s business domain and, thus, cannot be eliminated. On the other hand, modular design contains its effect by encapsulating the complex parts in proper modules, preventing its complexity from “spilling” across the system.
In terms of knowledge, modular design optimizes how knowledge is distributed across the components (modules) of a system.
Essentially, a module is a knowledge boundary. A module’s boundary defines what knowledge will be exposed to other parts of the system and what knowledge will be encapsulated (hidden) by the module. The three properties of a module that were introduced earlier in the chapter define three kinds of knowledge reflected by the design of a module:
Function: The explicitly exposed knowledge
Logic: Knowledge that is hidden within the module
Context: Knowledge the module has about its environment
An effective design of a module maximizes the knowledge it encapsulates, while sharing only the minimum that is required for other components to work with the module.
Deep Modules
In his book A Philosophy of Software Design, John Ousterhout (2018) proposes a visual heuristic for evaluating a module’s boundary. Imagine that a module’s function and logic are represented by a rectangle, as illustrated in Figure 4.3. The rectangle’s area reflects the module’s implementation details (logic), while the bottom edge is the module’s function (public interface).
Figure 4.3 A shallow module (A) and a deep module (B)
According to Ousterhout, the resultant “depth” of the rectangle reflects how effective it is at hiding knowledge. The higher the ratio between the module’s function and logic, the “deeper” the rectangle.
If the module is shallow, as in Figure 4.3A, the difference between the function and logic is small. That is, the complexity encapsulated by the module’s boundary is low as well. In the extreme case, the function and logic are precisely the same—the public interface reflects how the module is implemented. Such an interface provides no value; it doesn’t encapsulate any complexity. You could just as well read the module’s implementation. Listing 4.2 shows an extreme example of a shallow module. The method’s interface doesn’t encapsulate any knowledge. Instead, it simply describes its implementation (adding two numbers).
Listing 4.2 An Example of a Shallow Module
addTwoNumbers(a, b) { return a + b; }
On the other hand, a deep module (Figure 4.3B) encapsulates the complexity of the implementation details behind a concise public interface. The consumer of such a module need not be aware of its implementation details. Instead, the consumer can reason about the module’s functionality and its role in the overarching system, while being ignorant of how the module is implemented—at a higher semantic level.
That said, the metaphor of deep modules has its limitations. For instance, there can be two perfectly deep modules implementing the same business rule. If this business rule changes, both modules will need to be modified. This could lead to cascading changes throughout the system, creating an opportunity for inconsistent system behavior if only one of the modules is updated. This underscores the hard truth about modularity: Confronting complexity is difficult.
Modularity Versus Complexity
Modularity and complexity are two competing forces. Modularity aims to make systems easier to understand and to evolve, while complexity pulls the design in the opposite direction.
The complete opposite of modularity, and the epitome of complexity, is the Big Ball of Mud anti-pattern (Foote and Yoder 1997):
A Big Ball of Mud is haphazardly structured, sprawling, sloppy, duct-tape and bailing wire, spaghetti code jungle. These systems show unmistakable signs of unregulated growth, and repeated, expedient repair. Information is shared promiscuously among distant elements of the system, often to the point where nearly all the important information becomes global or duplicated. The overall structure of the system may never have been well defined. If it was, it may have eroded beyond recognition. —Brian Foote and Joseph Yoder
In the preceding definition of the Big Ball of Mud anti-pattern, unregulated growth, sharing information promiscuously among distant elements of the system, and important information becoming global or duplicated all demonstrate how unoptimized and inefficient flow of knowledge cripples systems.
These points can also be formulated in terms of ineffective abstractions. An effective abstraction removes all extraneous information, retaining only what is absolutely necessary for effective communication. In contrast, an ineffective abstraction creates noise by failing to eliminate unimportant details, removing essential details, or both.
If an abstraction includes extraneous details, it exposes more knowledge than is actually necessary. That causes accidental complexity in multiple ways. The consumers of the abstraction are exposed to more details than are actually needed to use the abstraction. First, this results in accidental cognitive load, or cognitive load that could have been avoided by encapsulating the extraneous detail. Second, this limits the scope of the abstraction: It is no longer able to represent a group of entities equally well, but only those for which the extraneous details are relevant.
On the other hand, an abstraction can fail if it omits important information. For example, a database abstraction layer that doesn’t communicate its transaction semantics may result in users expecting a different level of data consistency than the one provided by concrete implementation. This situation creates what is referred to as a leaking abstraction;7 that is, when details from the underlying system “leak” through the abstraction. This happens when the consumer of the abstraction needs to understand the underlying concrete implementation to use it correctly. As in the case of an abstraction sharing extraneous details, it increases the consumer’s cognitive load and can lead to misuse or misunderstandings of the module, complicating maintenance, debugging, and extension.
Hence, encapsulating knowledge is a double-edged sword. Going overboard can make it hard or even impossible to use the module, but the same is true when too little knowledge is being communicated. To make modularity even more challenging, even if a system is decomposed into seemingly perfect modules, it is still not guaranteed to be modular.
Modularity: Too Much of a Good Thing
In the beginning of the chapter, I defined modular design as one that allows the system to adapt to future changes. But how flexible should it be? As they say, the more reusable something is, the less useful it becomes. That is the cost of flexibility and, consequently, of modularity.
When designing a modular system, it’s crucial to watch for the two extremes: not making a system so rigid that it can’t change, and not designing a system to be so flexible that it’s not usable. As an example, let’s revisit the repository object in Listing 4.1. Its interface allows two ways of querying for customers: by name and by phone number. What would that interface look like if we were to attempt to address all possible query types; for example, by a wider range of fields, or even by looking up customers based on aggregations of values? That would make the interface much harder to work with. Moreover, optimizing the underlying database to efficiently handle all possible queries wouldn’t be trivial.
Hence, a modular design should focus on reasonable changes. In other words, the system should expose only reasonable degrees of freedom. For example, changing the functionality of a blog engine into a printer driver is not a reasonable change.
Unfortunately, identifying reasonable future changes is not an exact science, but is based on our current knowledge and assumptions about the system. An assumption is essentially a bet against the future (Høst 2023). The future can support or invalidate assumptions. However, if modular design entails making bets, we can gather as much information as possible and do our best to make informed bets.