At some point, developers working in modular software environments will likely encounter references to "high cohesion, low coupling." This turn of phrase refers to the balance between dependency and autonomy between the various modules that inhabit an application or software architecture.
However, the execution of this guiding principle isn't always as simple as its wording. Finding the right balance of coupling and cohesion requires close attention to the signs of overdependence, the degree to which they split code and the overall impact of application changes. This article will explore the idea of high cohesion and low coupling by examining the two terms independently, the relative tradeoffs involved and the approaches that can help maintain the right relationship between modules.
What is cohesion?
The concept of cohesion can be succinctly characterized using Robert C. Martin's famed single-responsibility principle: "A module should be responsible to one, and only one, actor." In other words, there should never be more than one reason for a class to change. This concept plays a foundational role in C programming languages, where standard modules are organized by themes like math, string, time and standard I/O.
With today's application development approaches and software architecture styles, these modules could take the form of subroutines, classes, APIs or individual microservices. No matter the module's form, however, components exhibit low cohesion if a single change to the system requires many other module changes in multiple different places. This will lead to a significant number of problems, not least of all the difficulty it adds to the process of finding errors within the codebase.
What is coupling?
To implement the single-responsibility principle effectively, we also need to focus on coupling, which refers to the direct, codependent relationships that exist between application components. In systems with tight coupling, component relationships are often fixed and rigid. In loosely coupled systems, those relationships tend to be more flexible and modular.
For example, it's arguable that the tight coupling found in a monolithic application architecture provides a valuable measure of stability and predictability. However, developers need to weigh that benefit against the flexibility restrictions a monolith imposes regarding updates and feature additions. A simple change to one interface component could induce a complicated series of file changes, recompiled code or even complete redeployments.
Loose vs. tight coupling in code
Imagine a programmer is writing code for an electronic data interchange application. When a file appears in a directory, the application picks it up, reads each line and sends those orders off to a production environment.
The programmer decides that the simplest way to write that process is to create a while loop that systematically scans the directory for new files until certain conditions are met. Once the loop algorithm recognizes a file, it waits five seconds to make sure the file's current volume of data isn't growing (i.e., actively receiving new data from a source). Once that's confirmed, the program will create a database connection, open the file, sort through it, run a few SQL inserts and move the file somewhere else.
However, if something goes wrong, the programmer will need to manually debug the file system by clearing and refreshing the database. This means the programmer will need to store the name of the database in a separate location and provide an alternative name for testing purposes. Finally, they'll need to use a series of SELECT statements to make sure the database returns the right results.
To improve this design, we'll look for what Michael Feathers calls "seams" in his book, Working Effectively with Legacy Code. Seams are places where it makes sense to split code into pieces; for example, write one program that checks a directory for changes, and then write another that inserts records into the database. That way, developers can test each loop, method, SQL process and other code components independently.
Instead of creating a new database connection inside the main method, the developer can now call a factory method that retrieves database objects. Those objects could be connections to a production database, test database or even a mock object. As such, there are now five separate components -- one for each of the following five tasks:
- Check a directory and deal with new files appropriately.
- Loop through a file, processing each row as a string.
- Create a SQL INSERT INTO statement for each string.
- Call a factory method to create database objects.
- Execute the necessary SQL commands.
Now, if a change needs to happen in this particular piece of software, that change is more likely to happen in one place. A real code implementation of one of these methods may look like this:
int ExecuteCommand(string SQL, database db)
Assuming exception handling is done elsewhere, this method is unlikely to ever change. It can probably safely be included in some higher-level method at the end. Yes, we could spend time and energy to make this independent because we might want to email the file later; we could implement the command pattern today.
Of course, this independence does not eliminate the need for thorough end-to-end testing. However, when tests continually fail or new requirements emerge, developers will have a much easier time extending that code. Keep in mind, though, that problems may emerge if programmers are continually forced to make flurries of unanticipated changes. A worst-case example of this would involve large software teams making a series of sweeping changes to an application that may inadvertently overlap with other critical system operations.
Design guidelines for systems
There is a vicious pitfall along the way to high cohesion and low coupling: Breaking a monolith into components adds a lot of code upfront. Consider a Java program that calculates utility bills for multiple buildings using a three-level nested for-each loop with a print statement. Those seven lines of code could easily become hundreds.
For one, the data needed to calculate average utility costs may live in an object with a three-dimensional array. Seeding that database will require the use of a factory method to create mock data. Combine that with each of the three for-each loops, which take elements of the array and loop through the various utility meters and account information for each building.
One could argue that the correct approach would be to use domain-driven design to make each of these sequences an object. The print statement can calculate a string, which it then sends to an output device (such as a screen), making it possible to mock that output device using dependency injection. A developer could also add an additional method to calculate the string, and then another method to write the data.
As you might have guessed, this design approach gets complicated. If you include unit tests, an activity that originally required seven lines of code now involves 700. Frankly, it's often unclear if that additional effort adds any real value -- and it's certainly possible to go too far. However, there are some general guidelines that can help developers maintain a balance:
- "Too big" is typically defined as anything beyond 60 lines (roughly one printed page) of code.
- When a program's codebase becomes too big, use refactoring to split it up.
- If test/deploy processes take more than three hours at a time, look for ways to refactor the architecture specifically to get that number down.
- If developers find themselves having to make changes to application logic in more than one place at a time, look for ways to improve cohesion in the system's design.
- If you find yourself maintaining artificial seams even when core code remains unchanged, take a moment to review the design. Decrease cohesion by joining components until the resulting collection becomes complex enough to reasonably require an eventual change.
- Consider falling back on the Extreme Programming principle of "You Aren't Gonna Need It" (YAGNI). This dictates that you should break software down into a new single responsibility only once the need emerges -- doing so any time before that may result in premature optimization.