Good software is not only about making each component work.
It is also about deciding:
- What belongs together?
- What should stay separate?
- Which modules should know about each other?
- How much knowledge should one component have about another?
- What should happen when one part of the system changes?
Two concepts help answer these questions:
Cohesion: how closely related the responsibilities inside a module are.
Coupling: how dependent one module is on other modules.
The goal is usually simple:
High cohesion within modules and low coupling between modules.
This sounds like a theoretical software engineering principle, but it directly affects how easy your system is to understand, test, scale, and change.
It works, but is that enough?
When developers build a feature under pressure, the first goal is usually to make it work.
That is understandable.
A client needs the feature. A product manager wants the release. A deadline is approaching. You write the endpoint, connect the database, call the external provider, return the response, and move on.
At that moment, the code may look perfectly fine.
The trouble starts when the system grows.
A simple order service begins to:
- Validate customer information.
- Calculate prices.
- Apply discounts.
- Check inventory.
- Process payment.
- Send emails.
- Publish events.
- Write audit logs.
- Generate invoices.
A small change to the payment provider now requires opening the order service. A change to email behaviour affects order creation. A new discount rule breaks an unrelated checkout flow.
The code still works—but it has become expensive to change.
This is often a design problem involving coupling and cohesion.
What is cohesion?
Cohesion describes how closely related the responsibilities inside a module are.
A highly cohesive module focuses on one clear purpose.
For example, an authentication module may contain:
- Login.
- Logout.
- Token refresh.
- Password reset.
- Session validation.
These responsibilities are related because they all belong to identity and access management.
A module that contains authentication, invoice generation, image resizing, and product recommendations has low cohesion.
The code may still compile, but the module does not have a clear identity.
A simple analogy
Imagine a toolbox.
A toolbox containing screwdrivers, pliers, measuring tape, and a wrench has a clear purpose: helping you perform repairs.
Now imagine opening the same toolbox and finding:
- Cooking utensils.
- Medical supplies.
- Office stationery.
- Car keys.
- Gardening tools.
The box may contain useful things, but it is no longer organized around one meaningful purpose.
Low-cohesion software feels the same way.
You find unrelated functionality in the same class or module, and you are never sure where the next responsibility should go.
High cohesion in code
Consider this service:

The name UserService does not explain the actual boundaries.
Some methods deal with users. Others deal with communication, billing, and reporting.
A more cohesive design might separate them:

Now each module has a clearer purpose.
When a developer needs to change password-reset behavior, they know where to look.
When invoice requirements change, authentication code is less likely to be affected.
That is the practical benefit of cohesion: related things stay together, and unrelated things stay apart.
What is coupling?
Coupling describes the degree of dependency between different modules.
Two modules are tightly coupled when one knows too much about the other or depends heavily on its internal details.
A change in one module may force changes in several others.
Consider:
- Paystack.
- Prisma.
- A specific payment response format.
- A specific database implementation.
If you replace Paystack, change the database library, or alter the payment workflow, this service must change.
That is tight coupling.
A loosely coupled design depends on stable contracts instead:
The order service depends on capabilities, not concrete infrastructure:

Now the payment provider and database implementation can change behind the abstractions.
Cohesion and coupling work together
Cohesion and coupling are not competing goals.
They describe two sides of software structure:
- Cohesion looks inside a module.
- Coupling looks between modules.
A healthy design aims for:
- High cohesion inside
- Low coupling between
Think of a well-organized company.
Each department focuses on a specific responsibility:
- Finance handles finances.
- Human resources handles people operations.
- Sales handles customers and revenue.
- Engineering handles product development.
The departments still communicate, but they do so through clear responsibilities and defined processes.
If every department performs every task, the organization becomes chaotic.
If departments cannot communicate at all, work also stops.
Software modules behave similarly.
The goal is not zero coupling.
The goal is controlled coupling.
Why zero coupling is impossible
Every useful system contains dependencies.
An order system needs payment information. A dashboard needs reporting data. A notification service needs information about events. A controller needs an application service.
The goal is not to remove every dependency.
The goal is to make dependencies:
- Necessary.
- Explicit.
- Stable.
- Small.
- Easy to replace.
- Easy to test.
- Easy to understand.
A controller depending on an application service is normal.
An application service knowing the private implementation details of three databases, two payment providers, and an email server is a warning sign.
Types of coupling
Coupling can appear in different forms.
Content coupling
One module directly accesses or modifies the internal data of another module.
This is highly dangerous because the consuming module depends on implementation details.
If internalState changes, the order service breaks.
Common coupling
Multiple modules depend on shared global state.

Global state can make behavior difficult to predict and test.
One module may modify the state while another module assumes it has not changed.
Control coupling
One module tells another how to behave by passing flags.
This can become problematic when the receiving service accumulates many branches:

Sometimes a strategy or channel abstraction is clearer.
Data coupling
One module passes only the data another module needs.

This is generally healthier because the dependency is explicit and limited.
Message coupling
Modules communicate through messages or events without depending heavily on each other’s internal structure.

Message-based communication can reduce direct coupling, though it introduces other concerns such as event versioning, delivery guarantees, retries, and eventual consistency.
Types of cohesion
Cohesion can also vary in strength.
Functional cohesion
A module contains elements that work together to perform one well-defined task.
Example:

Both methods belong to password hashing.
Sequential cohesion
One operation produces data consumed by another operation within the same module.
For example:

These operations form a clear sequence in a file-import workflow.
Communicational cohesion
Several operations work on the same data.
For example, a profile module may validate, update, and format user-profile data.
Coincidental cohesion
A module contains unrelated utilities simply because they were created around the same time.

This kind of module often grows into a dumping ground.
The problem with utility folders is not that utilities are always bad. The problem is that unrelated behaviour becomes difficult to own, test, and discover.
A practical example: order processing
Let us compare two designs.
Low cohesion and high coupling

This service has many responsibilities.
It is also coupled to several infrastructure systems.
Possible consequences:
- Large and slow tests.
- Difficult mocking.
- High risk of regression.
- Unclear error handling.
- Difficult reuse.
Every change affects the same class.
High cohesion and controlled coupling

Each component owns a focused responsibility.
The use case still coordinates the workflow, but the details are distributed across meaningful boundaries.
This design is not automatically perfect.
It may introduce more files and abstractions.
But if the application is complex and these concerns change independently, the separation creates value.
Coupling in a NestJS application
NestJS encourages dependency injection, which can help reduce coupling.
A service can receive dependencies through its constructor:

This is better than constructing concrete dependencies inside the service:

Constructor injection makes dependencies visible.
The service can be tested without connecting to a real database or sending real emails.
However, dependency injection alone does not guarantee low coupling.
You can still inject a massive concrete service that exposes too many responsibilities.
The quality of the boundary matters more than the presence of the injection mechanism.
Coupling in modular monoliths
A modular monolith may run as one deployable application while maintaining strong internal boundaries.
The modules can communicate through:
- Public application interfaces.
- Domain events.
- Commands.
- Queries.
- Shared contracts.
They should avoid reaching directly into one another’s internal repositories or private tables without a clear reason.
A modular monolith can provide many benefits of good boundaries without immediately introducing the operational complexity of microservices.
This is especially useful for early-stage SaaS products.
You can keep deployment simple while keeping responsibilities organized.
Coupling in microservices
Microservices do not automatically create low coupling.
A system can have separate deployable services and still be tightly coupled if:
- Every request requires five synchronous service calls.
- Services share the same database tables.
- One service depends on another’s internal schema.
- All services must deploy together.
- A small contract change breaks many consumers.
- A single service failure brings down the entire workflow.
This is distributed coupling.
The services are separate in code and deployment,but tightly connected in behaviour.
A well-designed microservice should own a meaningful capability and communicate through stable contracts.
But even then, network communication introduces costs:
- Latency.
- Timeouts.
- Retries.
- Partial failures.
- Versioning.
- Observability.
- Eventual consistency.
Sometimes a well-modularized monolith has less harmful coupling than a poorly designed microservice architecture.
The number of services is not a measure of design quality.
Cohesion and database design
Coupling and cohesion also apply to data.
A module should ideally own the data required for its responsibility.
For example:
- Billing module → subscriptions, invoices, billing transactions
- Inventory module → stock levels, reservations, warehouses
- Identity module → users, credentials, sessions, permissions
If every module directly reads and writes every table, the database becomes a hidden integration layer.
Changes become risky because nobody knows which services depend on which columns, indexes, or status values.
This does not mean every module must have a separate database.
A modular monolith can use one database while still enforcing ownership boundaries.
The important question is:
Who owns this data, and how should other modules access it?
Clear ownership improves cohesion.
Controlled access reduces coupling.
Coupling and event-driven design
Events can reduce direct dependency between modules.
Instead of the order module directly calling the notification module:

The notification module subscribes to that event.
This creates looser direct coupling because the order module does not need to know how notifications work.
However, event-driven systems introduce new trade-offs:
- Events may be delayed.
- Events may be delivered more than once.
- Consumers may process events out of order.
- Event schemas must evolve carefully.
- Debugging requires tracing across asynchronous flows.
- Data may become eventually consistent.
Events reduce some forms of coupling, but they do not eliminate system complexity.
They move the complexity from direct calls to contracts, delivery, and observability.
Signs of high coupling
Your codebase may be tightly coupled when:
- A small change requires edits across many modules.
- Tests need the entire application to run.
- Classes instantiate their own dependencies.
- Modules access one another’s private data.
- A shared utility module contains business logic for everything.
- Multiple services depend on the same database schema.
- Replacing a provider requires changing business logic.
- One failed dependency causes unrelated features to fail.
- Developers avoid refactoring certain areas because the impact is unpredictable.
High coupling creates a change ripple.
One adjustment travels through the system, touching code that should not have been affected.
Signs of low cohesion
A module may have low cohesion when:
- Its name is vague, such as CommonService or Utils.
- Its methods serve unrelated business functions.
- It changes frequently for different reasons.
- Developers are unsure where to add new functionality.
- The module has too many dependencies.
- Its tests cover unrelated behaviour.
- Removing one method leaves the remaining methods unrelated.
- The module requires a long explanation before anyone understands its purpose.
A useful question is:
If I had to describe this module in one sentence, would the description be clear?
If the answer is no, the module may need a better boundary.
How to improve cohesion
Group behavior by business capability
Instead of organizing everything by technical type:
- controllers/
- services/
- repositories/
- utils/
consider also thinking in terms of capabilities:
The best structure depends on the application, but grouping related behavior makes ownership clearer.
Keep related data and behavior close
If a business rule always operates on a particular concept, consider keeping the rule near that concept.
For example, order status transitions may belong to an order domain model or order service rather than a general utility file.
Separate unrelated reasons to change
If one module changes because of payment requirements, email requirements, and reporting requirements, it probably contains multiple responsibilities.
Split it around those change patterns.
Avoid generic dumping grounds
Be cautious with:
- utils.
- helpers.
- common.
- misc.
- shared-service.
These folders can be useful, but they often become places where code is stored without a clear owner.
How to reduce coupling
Depend on abstractions
Define contracts around capabilities:

The application service should not need to know the database library.
Use dependency injection
Pass dependencies into a component instead of constructing them internally.
This makes dependencies explicit and replaceable.
Encapsulate internal details
Expose what another module needs, not everything the module knows.
A module should not expose its internal database models, private state, and implementation-specific helper methods unnecessarily.
Prefer messages for some workflows
Events and commands can reduce direct knowledge between modules.
Use them where asynchronous behavior and eventual consistency are acceptable.
Define stable contracts
Use clear request and response models.
Avoid passing internal database entities everywhere. Internal data structures change more frequently than business contracts.
Limit shared mutable state
Shared mutable state is one of the easiest ways to create unpredictable coupling.
Prefer explicit inputs and outputs.
The balance: cohesion versus coupling
It is possible to overcorrect.
Suppose you split one simple function into ten classes.
You may reduce local responsibilities, but create excessive coordination and indirection.
This is sometimes called accidental complexity.
A good architecture balances:
- Cohesion.
- Coupling.
- Simplicity.
- Performance.
- Team understanding.
- Operational cost.
You do not want modules so large that everything is mixed together.
You also do not want modules so fragmented that understanding one workflow requires jumping through twenty files.
The right boundary is usually where:
- Responsibilities change together.
- Data is accessed together.
- Business rules belong together.
- The module can be explained clearly.
- The dependency is meaningful.
A useful code review framework
When reviewing a module, ask:
About cohesion
- Do these methods belong to the same business capability?
- Do they use the same data?
- Do they change for similar reasons?
- Does the module have one clear purpose?
About coupling
- What does this module know about other modules?
- Does it depend on implementation details?
- Can a dependency be replaced easily?
- Does a small change here affect many consumers?
- Are dependencies explicit?
About boundaries
- Who owns this business rule?
- Who owns this data?
- Is this communication synchronous or asynchronous?
- Is the contract stable?
- Is the abstraction solving a real problem?
These questions help you evaluate design beyond whether the code is syntactically clean.
The connection to SOLID
Coupling and cohesion are closely connected to SOLID principles.
- Single Responsibility improves cohesion.
- Open/Closed reduces unnecessary changes to stable modules.
- Liskov Substitution creates reliable contracts.
- Interface Segregation reduces unnecessary dependencies.
- Dependency Inversion reduces coupling to infrastructure.
They are also related to:
- Encapsulation.
- Separation of concerns.
- Domain-driven design.
- Modular architecture.
- Clean architecture.
- Hexagonal architecture.
- Event-driven design.
But the underlying idea remains simple:
Put related responsibilities together, and prevent unrelated components from knowing too much about one another.
Final thoughts
Good software is not made of isolated components that never communicate.
It is made of focused components that communicate intentionally.
High cohesion gives each module a clear identity.
Low coupling prevents changes from spreading unnecessarily.
When cohesion is low, modules become confusing.
When coupling is high, the entire system becomes fragile.
When both are poor, every feature becomes a negotiation with the existing codebase.
The goal is not to eliminate dependencies.
The goal is to make dependencies deliberate, visible, and manageable.
So, the next time you create a service, module, class, or microservice, ask two questions:
Does everything inside this component belong together?
And:
Does this component know more about the outside world than it needs to know?
Those two questions can prevent a lot of future pain.
Because maintainable software is not software with no complexity.
It is software where complexity has been given a clear place to live.








Top comments (0)