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.
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.
This is often a design problem involving coupling and 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
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
Some methods deal with users. Others deal with communication, billing, and reporting.
A more cohesive design might separate them:
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.
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:
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.
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.
Coupling can appear in different forms.
Content coupling
This is highly dangerous because the consuming module depends on implementation details.
If internalState changes, the order service breaks.
Common coupling
One module may modify the state while another module assumes it has not changed.
Control coupling
This can become problematic when the receiving service accumulates many branches:
Data coupling
Message coupling
Cohesion can also vary in strength.
Functional cohesion
Example:
Sequential cohesion
For example:
Communicational cohesion
For example, a profile module may validate, update, and format user-profile data.
Coincidental cohesion
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
Low cohesion and high coupling
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.
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.
It also makes testing easier:
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.
A modular monolith may run as one deployable application while maintaining strong internal boundaries.
For example:
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.
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.
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.
Events can reduce direct dependency between modules.
Instead of the order module directly calling the notification module:
it may publish an event:
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.
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.
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.
Group behavior by business capability
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
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
Split it around those change patterns.
Avoid generic dumping grounds
utils.
helpers.
common.
misc.
shared-service.
These folders can be useful, but they often become places where code is stored without a clear owner.
Depend on abstractions
The application service should not need to know the database library.
Use dependency injection
This makes dependencies explicit and replaceable.
Encapsulate internal details
A module should not expose its internal database models, private state, and implementation-specific helper methods unnecessarily.
Prefer messages for some workflows
Use them where asynchronous behavior and eventual consistency are acceptable.
Define stable contracts
Avoid passing internal database entities everywhere. Internal data structures change more frequently than business contracts.
Limit shared mutable state
Prefer explicit inputs and outputs.
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.
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.
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.
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.