Skip to main content

Coupling and Cohesion

Introduction

Glue Icon

Coupling and cohesion are two fundamental concepts in software design that help us determine how well the components of a system are organized. Coupling refers to the degree of interdependence between modules, while cohesion refers to the degree to which the elements within a module are related to each other. These forces need to be balanced to create a system that is easy to understand, maintain, and extend.

Coupling vs Cohesion

"Pull the things that are unrelated further apart, and put the things that are related closer together."
-- Kent Beck

Just arbitrarily splitting our system into modules does not produce a well designed system. We need to consider how the pieces fit together and how they will communicate.

Coupling

Coupling refers to the degree of interdependence between modules. It measures how much one module relies on another module. There are many different types of coupling, determined by how the modules are connected, but the exact type is not as important as the degree of coupling. Coupling is spectrum, ranging from low to high:

  • Low Coupling: Modules are independent of each other and communicate through well-defined interfaces. Changes in one module have minimal impact on other modules.
  • High Coupling: Modules are tightly bound together and changes in one module have far-reaching effects on other modules.

Lower coupling makes system easier to modify because it allows us to understand and change modules in isolation. Higher coupling often results in cascading changes, where a small change in one module requires changes in many other modules.

Zero Coupling

A system with zero coupling, where modules are completely independent of each other, doesn't do anything. The modules need to work together to achieve a common goal. The goal is to strike the right balance between coupling and cohesion to create a system that is easy to work with.

Types of Coupling

Michael Nygard, author of "Release It!", gives the following model of coupling:

  • Operational: A consumer can't run without a provider
  • Developmental: Changes in producers and consumers must be coordinated
  • Semantic: Change together because of shared concepts
  • Functional: Change together because of shared responsibility
  • Incidental: Change together for no good reason (e.g., breaking API changes)

Coupling at Scale

The importance of loose coupling increases as the size of the organization grows. A startup with a single team of developers can easily deal with a tightly coupled system. They can all sit in the same room and talk to each other about how the pieces fit together. For these companies creating a tightly coupled monolith is often the fastest way to get a product to market.

Organizations with multiple teams need to be more cautious about coupling. As the number of teams grows, the number of communication paths between them grows exponentially. This makes it difficult for everyone to understand how the pieces fit together. A loosely coupled system allows teams to work on different parts of the system without having to understand how every other part works.

Plant Icon

Cohesion

Cohesion refers to the degree to which the elements within a module are related to each other. It measures how well the elements in a module work together to achieve a common goal. Related functionality should be grouped together, while unrelated functionality should be separated. Cohesion is also a spectrum, ranging from low to high:

  • Low Cohesion: A module contains unrelated elements that don’t belong together. The elements are not well organized and don’t work together to achieve a common goal.
  • High Cohesion: A module contains elements that are closely related and work together to achieve a common goal. The elements are well organized and belong together.

Highly cohesive modules are easier to work with because everything you need to know about a particular piece of functionality is contained in one place. Low cohesion can lead to modules that are difficult to understand and maintain, as unrelated elements are mixed together. We might need to bounce around the codebase just to understand how a single piece of functionality works.

Advantages of Low Coupling

  • Improved understandability: Low coupling results in modules that are independent of each other and communicate through well-defined interfaces, making it easier for developers to understand the code and make changes. We can understand and change modules in isolation.
  • Better error isolation: Low coupling reduces the likelihood that a change in one part of a module will affect other parts, making it easier to identify and fix errors.

Advantages of High Cohesion

  • Improved maintainability: High cohesion results in modules that contain related elements that work together to achieve a common goal, making it easier for developers to understand and maintain the code. Everything you need to know about a particular piece of functionality is contained in one place.
  • Better reusability: High cohesion makes it easier to reuse modules in other parts of the system, as they contain well-organized and related elements.

Disadvantages of High Coupling

  • Increased complexity: High coupling makes it difficult to understand how the pieces of a system fit together, as changes in one part of the system have far-reaching effects on other parts. This can lead to bugs and errors.
  • Reduced maintainability: High coupling makes it difficult to make changes to a system, as changes in one part of the system have far-reaching effects on other parts. This can lead to a system that is difficult to maintain and extend.

Disadvantages of Low Cohesion

  • Decreased understandability: Low cohesion makes it difficult to understand how the elements in a module work together to achieve a common goal, as unrelated elements are mixed together. This can lead to a system that is difficult to understand and maintain.
  • Reduced reusability: Low cohesion makes it difficult to reuse modules in other parts of the system, as the elements are not well organized and don’t work together to achieve a common goal.

Design Objective

We need to strike the right balance between coupling and cohesion to create a system that is easy to work with.

Goal Icon

Loose Coupling and High Cohesion

The ideal scenario is to achieve loose coupling and high cohesion. This means that modules should be independent of one another, interacting only through well-defined interfaces, while each module is focused on a single, well-defined responsibility. Such a design results in a system that is easier to understand, maintain, and extend, as changes in one area have minimal impact on others.

However, it's important to recognize that every design choice involves trade-offs. It is possible to create a system with excessively loose coupling, which can make it difficult to grasp how the components fit together. In such systems, extending functionality can become challenging, as ensuring smooth interaction between disparate pieces requires significant effort.

That said, it is far more common for systems to be overly tightly coupled, rather than insufficiently coupled. This is often because tightly coupled systems are simpler to implement — developers can focus on making things work without having to carefully consider how different parts interconnect. However, this approach can lead to a system that is hard to maintain and extend, as changes in one component can have wide-reaching consequences across the entire system.

Cases to Avoid

Any other combination of coupling and cohesion is likely to lead to a system that is difficult to maintain and extend. Let’s look at some examples:

Tight Coupling and High Cohesion

If you create a system with tight coupling and high cohesion, you have created the God Object anti-pattern. This is a single class that knows about and does everything in the system. Such a system is a nightmare to maintain and extend. Each piece of functionality is tightly bound to every other piece, making it difficult to change or add new features. It is also tough to refactor, as any change in one part of the system can have far-reaching effects on other parts.

Tight Coupling and Low Cohesion

Having tight coupling and low cohesion implies that we have selected the boundaries of our modules poorly. This can lead to a system where unrelated pieces of functionality are tightly bound together, making it difficult to understand and maintain. It can also result in a system where changes in one part of the code have unintended consequences in other parts, leading to bugs and errors. You end up following a thread of spaghetti code, trying to understand how everything fits together.

Loose Coupling and Low Cohesion

Loose coupling and low cohesion is called deconstructive decoupling. This is where you have a system with many small, loosely coupled modules that don’t work together to achieve a common goal. Each module is isolated and doesn’t communicate with the others, making it difficult to understand how the system as a whole functions. This can lead to a system that is fragmented and lacks a clear structure, making it hard to maintain and extend.

This tends to happen when developers focus only on decoupling modules without considering how they should be organized to achieve a common goal. It is important to strike a balance between coupling and cohesion to create a well-structured system that is easy to understand and maintain.

Bringing Pieces Together

Which pieces belong together and which pieces should be kept apart? This is a difficult question to answer, as it depends on the specific requirements of the system you are building. This lack of absolute answers is part of what makes software design such a creative endeavor.

Simplicity Icon

Accidental vs Essential Complexity

Just like with Separation of Concerns, we need to consider the difference between accidental and essential complexity when determining how to structure our system. Essential complexity is the complexity that is inherent in the problem we are trying to solve, while accidental complexity is the complexity that arises from the way we choose to solve the problem.

Essential Complexity

In many cases, pieces of essential complexity should be brought closer together. We don't want our business logic spread out across many different modules. We want to keep it together so that we can understand how it works and make changes to it easily.

This isn't to imply that we should have a single module that contains all of the business logic, but rather that we should group related pieces of business logic together. For example, if you are building an e-commerce system, you might have modules for inventory management, order processing, and customer management. Each of these modules would contain the business logic related to that area of the system.

It is often better to group essential complexity until you have a good reason to split it apart.

Accidental Complexity

Accidental complexity should be kept isolated from any essential complexity. We don't want our database logic, UI rendering, or file I/O code mixed in with our business logic. We want to keep it separate so that we can change it easily without affecting the rest of the system.

Similar types of accidental complexity should generally be grouped together. For example, all of your database logic should be in one place, all of your UI rendering code should be in another place, and all of your file I/O code should be in yet another place. Consider whether the pieces will change together, and if they won't, keep them apart.

Bounded Context

One approach to determining which pieces belong together is to look at how terminology is being used. Words will change meaning depending on the context in which they are used. For example, the word "order" means something different in a restaurant than it does in a warehouse. The same module should not be responsible for two different meanings of the same word.

If you have two pieces of code that use the same terms in different ways, they probably belong in different modules. If you have two pieces of code that use different terms to describe the same thing, the language should be unified and probably combined into a single module.

What is a tomato?

For an amusing example of how words can mean different things in different contexts, consider the word "tomato." In a culinary context, a tomato is a vegetable because it is a savory plant ingredient. In a botanical context, a tomato is a fruit because it is the seed-bearing structure of a flowering plant.

Things get even weirder if you consider the legal context. In the United States, the Supreme Court ruled in 1893 that a tomato is a vegetable for tariff purposes. The court reasoned that tomatoes are usually served with dinner and not as a dessert, so they should be classified as a vegetable.

Finally, in a theater, a tomato is a means of audience feedback.

Getting familiar with your domain and understanding how terms are used in different contexts can help you determine which pieces of code belong together.

What is the Right Balance?

Getting the exact right balance between coupling and cohesion is impossible. There is no perfect system, and the closer we get to the right balance the more difficult it is to improve. Even if we thought we had the perfect balance, some other engineer would have a different opinion about how it should be structured.

Avoid concentrating too much on getting this perfect during your initial design, and instead expect your ideas of what belongs where to change over the course of a project. Pick the balance that works well enough for now, and be prepared to change it as the project evolves. As you get new information, refactor your code to reflect the new understanding. This willingness to refactor is critical for keeping your system maintainable over time.

Examples

Digestible examples of low coupling and high cohesion are difficult to come by. This is because the best examples are the ones you don't notice. When you are working with a system that is well designed, you don't notice the design. You just write code that works.

Instead let's start with a piece of code that is poorly designed and refactor it to be better. This will give you a sense of what to look for when you are designing your own systems. Note, that the example is intentionally simple to allow us to focus on the design principles.

class WordProcessor {
fun loadProcessAndStore() {
val text = File("./resources/input.txt").readText()
val words = text.split(" ")
val processedWords = words.map { it.upperCase() }

File("./resources/output.txt").writeText(processedWords.joinToString("\n"))
}
}

This class is responsible for loading a file, processing the text, and storing the result in another file. This sort of basic workflow is pretty common in software development. By replacing the data source, processing logic, and data sink we can create just about any data processing application. However, this design has some serious problems that will make it difficult to maintain and extend.

Some of the potential issues include:

  • Tight coupling to the file system makes it difficult to test and reuse the class.
  • Mixing the file loading, text processing, and file writing logic makes it difficult to modify.
  • The class is not reusable. It is tightly bound to the specific file paths and processing logic.

Let's start with a simple refactor to split the file loading, text processing, and file writing logic into separate methods.

class WordProcessor {
fun loadProcessAndStore() {
val words = loadText()
val processedWords = processWords(words)
storeText(processedWords)
}

private fun loadText(): List<String> {
val text = File("./resources/input.txt").readText()
return text.split(" ")
}

private fun processWords(words: List<String>): List<String> {
val words = text.split(" ")
return words.map { it.upperCase() }
}

private fun storeText(processedWords: List<String>) {
File("./resources/output.txt").writeText(processedWords.joinToString("\n"))
}
}

This is a little better, but we still have some issues. The class is still tightly bound to the file system, making it difficult to test and reuse. The essential and accidental complexity are isolated in different methods, but they are still mixed together in the class. Let's refactor the class into separate classes for the file I/O and text processing logic.

// Essential Complexity - Processing text
class WordProcessor(private val textIO: TextIO) {
fun loadProcessAndStore() {
val words = textIO.loadText()
val processedWords = processWords(words)
textIO.storeText(processedWords)
}

private fun processWords(words: List<String>): List<String> {
val words = text.split(" ")
return words.map { it.upperCase() }
}
}

// Accidental Complexity - Interacting with the file system
class TextFileIO: TextIO {
fun loadText(): List<String> {
val text = File("./resources/input.txt").readText()
return text.split(" ")
}

fun storeText(words: List<String>) {
File("./resources/output.txt").writeText(words.joinToString("\n"))
}
}

This is a much better design. The essential complexity is isolated in the WordProcessor class, while the accidental complexity is isolated in the TextFileIO class. This makes it easier to test and reuse the classes, as they are not tightly bound to the file system. It also allows us to test the word processing logic without having to interact with the file system, just by using a mock TextIO object.

This still isn't great, but is a reasonable start. Having all of the accidental complexity in one place is a little silly, and we still have hard-coded file paths.

One way to decouple things even further would be to use a change listener to trigger the storage of the processed text. This would allow us to change the storage mechanism without having to modify the WordProcessor class.

interface WordSource {
fun loadText(): List<String>
}

interface WordsListener {
fun onWordsChanged(words: List<String>)
}

class WordProcessor(private val wordSource: WordSource, private val wordsListener: WordsListener) {
fun loadProcessAndStore() {
val words = wordSource.loadText()
wordsListener.onWordsChanged(processWords(words))
}

private fun processWords(words: List<String>): List<String> {
val words = text.split(" ")
return words.map { it.upperCase() }
}
}

Notice that in every case, the decoupled implementation was longer than the original block of code. Looser coupling always requires more code and more implementation time. This is why it is so tempting to just write code that works, rather than code that is well designed.

Conclusion

Coupling and cohesion are two fundamental concepts in software design that help us determine how well the components of a system are organized. Coupling refers to the degree of interdependence between modules, while cohesion refers to the degree to which the elements within a module are related to each other.

Getting the right balance between coupling and cohesion is a difficult task that requires careful consideration of the specific requirements of the system you are building. It is important to strike a balance between coupling and cohesion to create a well-structured system that is easy to understand and maintain.

Image Credits