If you’re like most people, you haven’t heard about “cohesion” a lot—except maybe in English class. “Coupling” is still a rarer word. But if you’re a software engineer, you probably hear—or read—these two words more often than most people. Well, now you’re about to read them several times more because this post is all about cohesion vs coupling.
Have you ever mixed the two words up? Or maybe people keep telling you to write code that is “highly cohesive and lowly coupled,” but when pressed, they can’t explain what that means. Whatever the case, this post is for you.
By the end of the post, you’ll have a more robust understanding of coupling, cohesion, and how they affect the maintainability of your code. Let’s get started.
What Is Coupling?
In the context of software development, coupling means the degree to which a software artifact—a class, a function, a module—knows about and relies upon a different software module.
Generally speaking, you want your code to have as low coupling as possible. In other words, you want your application to be composed of small pieces that are as independent of each other as possible.
Low Coupling Is Good. But Why?
Have you ever changed an area of the codebase and then realized it caused a defect in a completely different part of the application? That’s caused by coupling. You have unrelated parts of the application that know more about each other than they should.
To sum it up: excessive coupling leads to code that is too complex, harder to maintain, and harder to reason about. It also creates a higher likelihood of introducing bugs.
What Is Cohesion?
If your assignment is to write an essay about the French revolution, you shouldn’t also talk about the discovery of water on Mars, the 2016 Rio Olympic Games, and how terrible the ending of Game of Thrones was. Stay on track. That’s cohesion.
The same concept applies to software. Cohesion here means that your function/classes/modules shouldn’t try to do everything under the sun but specialize and do their unique tasks well.
Why Is High Cohesion Good?
Code that does a lot of things is more challenging to understand than code that does fewer things. A function that does a lot is probably harder to test as well; it’s likely to have a higher cyclomatic complexity, which increases the number of tests it would take to achieve full branch coverage.
Code that isn’t cohesive is likely to mix different levels of abstraction. For instance, think about a class belonging to the presentation layers that also messes with domain logic. Or a function that, despite looking like a simple business logic function, also adds a line to a database table.
This kind of code brings many problems to the table:
- You struggle to understand it because it does so many different things, including generating side effects
- It’s hard to test it because it might use external dependencies in a hard-coded way
- It doesn’t promote reusability—since different levels of abstraction are all mingled together—which might cause code duplication
- Low cohesive code makes it hard to navigate through the codebase, harming developer productivity
Cohesion vs Coupling Examples
I’ll use C# for the examples because that’s the language I’m most comfortable with. That said, the examples should be easy to understand regardless of your favorite programming language.
Also, bear in mind that the examples are simple because there’s only so much I can do in a short blog post. Consider the examples I show here as proxies for much more complex, real-life code.
#1. Business Logic Tightly Coupled to Presentation
This first example is somewhat silly, but it’s also didactic. Consider the following code:
Console.WriteLine("Enter the number for which you want to generate the multiplication table:"); if (!int.TryParse(Console.ReadLine(), out var number)) { Console.WriteLine("You didn't enter a valid number! Exiting..."); return; } for (var i=1; i <= 10; i++) { Console.WriteLine($"{number} X {i} = {number*i}"); }
The code above is the complete Program.cs class of a .NET 6 console app I created for this example. The program asks for a number and then displays the multiplication table for that number. You might have coded something like this in the first week of computer science school.
What’s wrong with the code above? Simple: you have business logic—the part of the code that calculates the multiplication table—tied with the presentation code. What are the consequences?
- The code isn’t easily testable—as in unit tests.
- The code isn’t easily reusable—you’d have to resort to copying and pasting to reuse the logic for calculating the multiplication table.
The solution here would be to extract the multiplication table logic into a separate class. This can be as simple or as sophisticated as the situation asks for. I went with a simple static class:
public static class MathUtils { public static string GenerateMultiplicationTableFor(int number) { string result = string.Empty; for (var i=1; i <= 10; i++) { result += $"{number} X {i} = {number*i}" + Environment.NewLine; } return result; } }
In the original code, we now replace the table’s generation with a call to the new class:
Console.WriteLine("Enter the number for which you want to generate the multiplication table:"); if (!int.TryParse(Console.ReadLine(), out var number)) { Console.WriteLine("You didn't enter a valid number! Exiting..."); return; } Console.WriteLine(MathUtils.GenerateMultiplicationTableFor(number));
Now, you can reuse the code in the MathUtils class and even unit test it easily!
#2. Code Tightly Coupled to the Environment
Let’s say you have a class with the following method:
public string GenerateStamp(string originalName, int limit) { return originalName.Substring(0, limit) + DateTime.Today.ToString("d"); }
The first problem here is that this method is coupled with a call to DateTime.Now. How are we supposed to unit test this method if the time never stops passing?
A better design would be to take the DateTime value itself as a dependency, like this:
public string GenerateStamp(string originalName, int limit, DateTime date) { return originalName.Substring(0, limit) + date.ToString("d"); }
Now I can write a unit test for the method:
public void Test1() { Notary n = new Notary(); string expected = "Coh17/11/2021"; string actual = n.GenerateStamp("Cohesion", 3, new DateTime(2021, 11, 17)); Assert.AreEqual(expected, actual); }
The test passes. But the production code still has problems. If you live in the US, the test would probably fail on your machine. Can you guess why?
It has to do with the call to ToString() on the DateTime object. Quick C# lesson here: the “d” formatting code means “short date string” when passed to the ToString on a DateTime object. So far, so good—what’s the problem?
This specific form of the method relies on the system’s culture to decide the date format. I’m from Brazil, and around here, the date format is dd/MM/yyyy. If your default format is MM/dd/yyyy, the test above will fail.
There are several ways to fix that. Let’s suppose that, for whatever reason, the dd/MM/yyyy format should be the correct output of the method, no matter where you are. In such a case, it’d make sense to hardcode the format:
return originalName.Substring(0, limit) + date.ToString("dd/MM/yyyy");
That way, we ensure the code always returns the same result, regardless of where it’s run.
#3. Function That Does Too Much
For our final example, let’s consider a function that commits both sins:
static async Task<double> GetAverageStars(string user) { IEnumerable<Repository> repos = await GetReposFrom(user); repos = repos.OrderBy(x => x.Stars).Skip(1); return repos.Average(x => x.Stars); }
The method is quite simple. In its first line, the code fetches (GitHub) repositories for the specified user—I could show you the code of GetReposFrom(user), but it’s not that relevant.
Over the next two lines, the method processes the repositories: it orders them by the number of stars, skips the repo with the lowest number of stars, and returns the average number of stars from the remaining repositories. Sure, this operation doesn’t make a lot of sense. Here, it serves as a proxy for a more complex, real-world business operation.
So, what’s the problem with this method? For starters, the function is coupled with an external dependency—an HTTP call to an API. Because of that, the code isn’t easily testable.
Also, it’s not easy to reuse the code in GetAverageStars without relying on copying and pasting. What if you wanted to perform that calculation on a list of repositories from somewhere other than the GitHub API, like a local database?
The solution is simple: let’s change the function so it takes the list of repositories as a parameter:
static double GetAverageStars(IEnumerable<Repository> repos) { return repos .OrderBy(x => x.Stars) .Skip(1) .Average(x => x.Stars); }
The function can now calculate the average stars of any sequence of repositories, no matter where the repositories came from. Since it’s a pure function, it’s deterministic, thus easily testable.
You might wonder whether it’s worth it to have a dedicated function for what’s now essentially a one-liner. But remember: this code is here in lieu of a real-world example. Use your imagination!
Couple Your Code Only To High Quality!
“Write low-coupled, high cohesion code” is a common piece of advice. But often, people don’t explain what code like that looks like or how to achieve it. In this post, I set out to fix that.
If you want to learn more about how to write code that’s easy to read and maintain, you might want to research these concepts:
- CQS (command-query separation) principle
- Purity of functions
- The single-responsibility principle
Thanks for reading, and until next time!