{ } Programming

Coupling and cohesion: designing modules that are easy to change

Coupling and cohesion explained with examples: why cohesive, loosely dependent modules make software easier to change, test and understand over time.

When people talk about software architecture you come across many acronyms and principles: SOLID, layers, hexagons, microservices. Behind almost all of them, however, lie two older and simpler ideas, already formulated in the 1970s in the context of structured design: cohesion and coupling. Understanding them well lets you evaluate any design choice with a practical question: how easy will it be to change this code tomorrow?

Cohesion: each module has one purpose

Cohesion measures how much the elements of a module (a function, a class, a file, a package) really belong together. A highly cohesive module does one thing and all its parts contribute to that purpose.

An example of low cohesion is the classic utils.py file that contains everything: a function to format dates, one to send emails, one to calculate VAT, one to resize images. There is no reason for these functions to be together, other than that nobody knew where to put them.

A cohesive module, by contrast, can be described in one sentence without using "and": "this module calculates prices", "this class represents an order and its rules".

Why does it matter? Because when a rule changes, the change stays confined to one place. If the pricing rules are scattered across ten files, every change requires finding them all, and sooner or later one slips through.

Coupling: how much modules depend on each other

Coupling measures how much a module depends on the internal details of another. Tight coupling means that changing one module forces changes to others.

Some signs of excessive coupling:

  • a module directly accesses another's internal data instead of using its public functions;
  • a change to the database schema requires changes to the user interface;
  • testing one function requires starting half the system;
  • call chains like order.customer.address.city.province.code, which tie the code to the internal structure of many objects.

The goal is not to eliminate coupling, which is impossible: modules must collaborate. The goal is for them to depend on stable, small interfaces, not on details that change often.

The desired balance

Low cohesion High cohesion
Tight coupling The worst case: everything depends on everything Tidy but entangled modules
Loose coupling Independent but muddled modules The goal: clear, autonomous modules

The two properties are linked: when each module gathers everything relating to its purpose, it has less need to ask others for details.

A concrete example

Here is a service that sends an order confirmation:

class OrderService:
    def confirm(self, order):
        order.status = "confirmed"
        server = smtplib.SMTP("mail.example.com", 587)
        server.login("user", "password")
        server.sendmail("shop@example.com", order.email, "Order confirmed")

The order logic is tied to a specific way of sending email, to a particular server and to credentials written in the code. Testing the confirmation requires a mail server; switching to a different sending service means modifying the order class.

A version with looser coupling:

class OrderService:
    def __init__(self, notifier):
        self.notifier = notifier

    def confirm(self, order):
        order.status = "confirmed"
        self.notifier.send(order.email, "Order confirmed")

Now OrderService depends only on an object with a send method. In tests you pass a fake notifier that records messages; in production one that uses email; tomorrow, perhaps, one that sends text messages. This technique, passing dependencies in from outside instead of creating them inside, is called dependency injection.

How to apply these principles

Some practical habits:

  1. group by what changes together: if two elements are always modified on the same occasion, they probably belong in the same module;
  2. expose little: make public only what other modules need; in Python, the _ prefix conventionally marks what is internal;
  3. isolate the boundaries: databases, external services, the file system and the user interface are the points where it pays to introduce an interface, because they are the most likely to change;
  4. be wary of "common" modules: packages like common or helpers tend to become low-cohesion dumping grounds that everything depends on;
  5. listen to the tests: if a class is hard to test, it is often because it is too tightly coupled.

Do not overdo it

The opposite risk exists: interfaces for everything, abstraction layers that hide nothing, ten files for a simple operation. This too makes code hard to understand. The practical rule is to introduce an abstraction when there is a concrete reason, such as a test to write or a real second implementation, not for a hypothetical variation that may never come.

In short

  • Cohesion measures how much a module's parts belong to a single purpose.
  • Coupling measures how much a module depends on the internal details of others.
  • The goal is high cohesion and loose coupling: clear modules collaborating through stable interfaces.
  • Dependency injection reduces coupling and makes code easier to test.
  • Introduce abstractions for concrete reasons, not for future hypotheses.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. With degrees in computer science and psychology, he combines technical analysis with an understanding of the human factor.

Read also