A codebase can be split into many files and still be difficult to change. A small feature may require edits in several modules, tests may need large fixtures, and one internal change may unexpectedly break distant code.

The problem is often not the number of modules. It is where their boundaries are drawn.

Two ideas are especially useful when evaluating those boundaries: cohesion and coupling. Cohesion asks whether the responsibilities inside a module belong together. Coupling asks how much one module depends on the details of another. Used together, they provide a practical way to decide where code should live and which dependencies deserve attention.

Think about the cost of a change

A useful module boundary keeps a common kind of change reasonably local.

Imagine an application that calculates shipping quotes. The rules for package weight, destination zones, discounts, and quote expiry all belong to the same business capability. If those rules are scattered across controllers, utility files, database helpers, and UI-specific code, changing the quoting policy requires knowledge of several unrelated areas.

Now imagine those rules are grouped behind a shipping quote module. Other parts of the application provide inputs and receive a result without knowing how the quote is calculated.

The second design does not eliminate dependencies. It gives them a clearer direction and keeps more of the reason for change in one place.

This leads to a practical mental model:

  • High cohesion: code that changes for closely related reasons is kept together.
  • Low coupling: code depends on as little external detail as it reasonably can.

Neither is an absolute score. They are design qualities to examine in the context of real changes.

A cohesive module has a purpose that can be explained without a long list of unrelated duties.

Consider a module called OrderService that:

validates order lines
calculates totals
formats invoice HTML
sends marketing email
rotates application logs

The name hides several different responsibilities. A change to log retention has little relationship to a change in order pricing. Keeping both in the same module means unrelated concerns share the same boundary.

A more cohesive design might separate them conceptually:

OrderPricing
InvoiceRenderer
MarketingNotifier
LogMaintenance

This does not mean every responsibility needs its own class or file. Excessive splitting can make simple behavior harder to follow. The useful question is whether the code inside a boundary participates in the same kinds of changes.

Look for reasons that code changes together

Physical proximity alone does not create cohesion. Two functions may sit next to each other while serving unrelated purposes. Conversely, several operations may be strongly related even if they perform different steps.

For example, these operations can form a cohesive subscription-renewal capability:

check renewal eligibility
calculate renewal price
create renewal record
produce renewal outcome

They are different operations, but they collaborate to implement one business responsibility.

A good test is to imagine a requirement change. If changing one rule regularly requires coordinated edits to the same small group of code, that group may represent a useful module boundary.

Coupling is about required knowledge

Modules need to collaborate, so zero coupling is neither realistic nor desirable. The goal is to avoid dependencies on details that do not need to cross a boundary.

Suppose checkout code needs a shipping quote. It could reach directly into the shipping implementation:

checkout
  -> zone table
  -> weight rounding helper
  -> discount rules
  -> quote expiry calculation

Now checkout knows several details of shipping policy. A change to the zone representation or rounding rule can force checkout to change even though checkout’s responsibility has not changed.

A narrower dependency looks more like this:

checkout
  -> shipping quote interface
       -> shipping rules

Checkout still depends on shipping behavior. That dependency is necessary. What changed is the amount of knowledge crossing the boundary.

The interface might conceptually be as small as:

quote = shipping.quote(destination, package)

The caller needs to know the required inputs, the result, and relevant failure behavior. It does not need to know which tables or calculations produce the quote.

Prefer dependencies on stable decisions

Not every dependency is equally costly.

A dependency on a stable domain concept such as Money may be harmless across many modules. A dependency on the internal fields of another module’s cache is more fragile because those fields are implementation choices.

When reviewing a dependency, ask:

  1. Does the caller need this information to do its job?
  2. Is it depending on a public capability or an internal representation?
  3. If the provider changes its implementation, should the caller have to change?

The third question is particularly revealing. If the answer is no but the caller would still break, the boundary is leaking unnecessary detail.

Improve cohesion and coupling together

These ideas work best as a pair. Optimizing one while ignoring the other can produce poor designs.

Suppose every pricing rule is moved into its own tiny module. Each module may appear narrowly cohesive, but calculating a price could require a chain of calls through many boundaries. The design gains coordination overhead and may become harder to understand.

At the other extreme, putting the entire pricing workflow into one large module can reduce external calls while mixing unrelated policies, persistence, formatting, and notification behavior.

A better boundary usually groups behavior that shares a meaningful responsibility while exposing a small interface to the rest of the system.

The target is not “the smallest possible module.” It is a boundary that contains a coherent decision and hides details that other code does not need.

Use change patterns as evidence

Architecture diagrams can suggest boundaries, but the history of real changes provides stronger evidence.

Watch for patterns such as:

  • a feature repeatedly requiring edits across the same set of files;
  • two modules that almost always change in the same pull request;
  • a supposedly independent module that imports many internals from another;
  • tests that require constructing large parts of the system for a small behavior;
  • duplicated rules appearing because the original owner is difficult to call safely.

These signals do not automatically prescribe a refactoring. They indicate where to investigate.

For example, if Checkout, ShippingController, and OrderFormatter all contain variations of shipping eligibility logic, the first step is not to create three new abstractions. First identify the decision they are all trying to make. That decision may deserve one clear owner.

Refactor around one concrete change

Large “improve modularity” projects are risky because the desired boundary can remain abstract. A real change gives you a test for whether a boundary is useful.

Suppose a new requirement says that refrigerated packages cannot use certain shipping methods.

Start by tracing where that decision currently appears. You might find checks in checkout validation, quote calculation, and dispatch preparation. Then ask which capability should own the rule.

If shipping policy is the natural owner, move the decision there and make callers depend on its outcome rather than duplicating the rule.

The resulting interaction could be:

checkout -> shipping policy -> eligible methods

dispatch -> shipping policy -> eligible methods

Both callers remain coupled to the shipping policy because they genuinely need its decision. They no longer need to know the refrigerated-package rule itself.

This is a useful form of decoupling: not removing collaboration, but moving knowledge to the place that owns it.

Keep interfaces smaller than implementations

A module may contain substantial internal complexity while presenting a small public surface.

This is valuable because every public operation creates something callers can depend on. If internal helpers are routinely imported by other modules, implementation choices gradually become shared contracts.

Before exposing an operation, ask whether callers need a capability or merely want a convenient shortcut into internal logic.

For example, a billing module might expose:

create_invoice(order)
void_invoice(invoice_id, reason)

It may internally contain tax selection, line aggregation, numbering, and rounding logic. Exposing all those helpers would allow callers to assemble invoices in ways the billing module cannot control.

A small interface preserves freedom to change the implementation while keeping the behavior callers actually need.

Do not hide important coupling

Reducing visible dependencies by routing everything through a global registry, event bus, or service locator does not necessarily reduce coupling. It can make dependencies harder to see.

If module A cannot work correctly unless module B reacts to an event, there is still a behavioral dependency even when A never imports B.

Indirect communication can be appropriate when multiple independent consumers need a notification or when asynchronous processing is part of the design. It should not be used merely to make a dependency graph look cleaner.

Prefer dependencies that are understandable from the code and architecture. Visible, intentional coupling is often easier to maintain than hidden coupling.

Avoid turning metrics into targets

Some tools calculate coupling or dependency metrics. These can help identify unusual areas, but a number cannot determine the right architecture by itself.

A module with several dependencies may be appropriate if its responsibility is to coordinate them. A module with only one dependency can still be badly coupled if it relies on that dependency’s unstable internals.

Similarly, counting methods or files does not tell you whether a module is cohesive. Cohesion is about the relationship between responsibilities, not simply size.

Use metrics to ask better questions, not to replace design judgment.

Know when not to split a module

A boundary has a cost. It introduces an interface, naming decisions, dependency direction, and often additional tests or adapters.

Keeping code together can be better when:

  • the behavior is small and changes as one unit;
  • the proposed boundary would expose almost every internal detail;
  • the two parts have no meaningful independent lifecycle or responsibility;
  • separation would mainly add forwarding methods and navigation overhead;
  • there is not yet enough evidence to know where the stable boundary lies.

Premature separation can freeze an inaccurate model. It is reasonable to keep closely related code together until change patterns reveal a useful seam.

Review boundaries with practical questions

When a module becomes difficult to change, review it with a short set of questions:

  1. What responsibility does this module own?
  2. Which pieces inside it change for unrelated reasons?
  3. Which external details does it know that it should not need?
  4. Which internal details are callers depending on?
  5. Can a common change be completed mostly inside one boundary?
  6. Would a smaller public interface preserve the same useful capability?

These questions are more actionable than simply trying to “increase cohesion” or “reduce coupling.” They connect the design principles to the maintenance work developers actually perform.

Conclusion

Cohesion and coupling are useful because they turn modularity into questions about change and knowledge.

Keep responsibilities together when they participate in the same decisions and changes. Keep dependencies narrow by exposing capabilities while hiding implementation details. Do not try to eliminate every dependency or split code into the smallest possible pieces.

A strong module boundary makes an important kind of change easier to locate, understand, and complete without forcing unrelated parts of the system to change with it.