Coupling and cohesion: balancing dependencies and focus
Measure and improve your design by reducing coupling between modules and increasing cohesion within them, with Java examples and practical metrics.
You refactor a class into three smaller ones. The tests are cleaner, the PR looks good, and you merge it. A week later, a teammate changes class A and class B breaks. Another week, someone adds a feature to class C and accidentally duplicates logic already in class A. The classes are separate, but the separation did not actually help.
The missing lens is coupling and cohesion. Splitting code into modules is not enough. You need to measure two things: how much your modules depend on each other (coupling), and how focused each module is on a single purpose (cohesion). These two metrics together tell you whether your design is genuinely modular or just spread across more files.
I use coupling and cohesion as the first diagnostic questions in every design review. High coupling? Something is tangled. Low cohesion? Something is a junk drawer.
What Are Coupling and Cohesion
Coupling is the degree of interdependence between modules. When module A depends on module B's internal data structures, concrete classes, or execution order, they are tightly coupled. A change in B forces a change in A.
Cohesion is the degree to which elements inside a module belong together. A class with high cohesion has methods and fields that all serve one focused purpose. A class with low cohesion is a grab bag of unrelated responsibilities glued together by convenience.
Think of coupling and cohesion like rooms in a house. Coupling is how many pipes and wires run between rooms. Cohesion is whether each room has one clear function (kitchen, bedroom) or is a combo kitchen-bedroom-office that does nothing well. You want clean rooms with minimal shared plumbing.
For your interview: the goal is always low coupling, high cohesion. Say it once, then show it in your design. Every good design pattern exists to move you toward that target.
In the left diagram, every module reaches into every other module's internals. Change one and you break all three. In the right diagram, modules communicate through a shared interface. Change the internals of any module and the others never notice.
Types of Coupling
Coupling exists on a spectrum from tight (avoid) to loose (prefer). Each level describes a different kind of dependency between two modules.
| Type | What it means | Example | Severity |
|---|---|---|---|
| Content | Module A reaches into B's internals and modifies private fields or jumps into B's code | Using reflection to set a private field | Worst |
| Common | Multiple modules read/write the same global or static mutable state | Two services sharing a static HashMap | Very bad |
| Control | A passes a flag to B that dictates B's internal control flow | process(order, /* skipValidation */ true) | Bad |
| Stamp | A passes an entire object to B, but B only uses one field | Passing User when you only need userId | Moderate |
| Data | A passes only primitive or simple data that B needs | calculateTax(subtotal, taxRate) | Good |
| Message | A and B communicate via async messages with no direct method call | Publishing an OrderPlaced event to a queue | Best |
The practical rule: move your dependencies as far down this table as possible. If you are passing a whole User object just so the method can read user.getId(), pass the ID directly. If you are calling a method with a boolean flag, split it into two methods.
I find that most coupling problems in real codebases sit at the control and common levels. Global mutable state and boolean flag parameters are the two patterns I flag most often in reviews.
Types of Cohesion
Cohesion also exists on a spectrum, from coincidental (worst) to functional (best). Each level describes how related the elements inside a module are.
| Type | What it means | Example | Quality |
|---|---|---|---|
| Coincidental | Elements grouped by accident, no logical relationship | A Utils class with formatDate(), parseJson(), sendEmail() | Worst |
| Logical | Elements do similar things but are unrelated in purpose | An InputHandler that handles keyboard, mouse, and file input | Poor |
| Temporal | Elements grouped because they run at the same time | A Startup class that initializes the DB, loads config, and warms cache | Weak |
| Procedural | Elements grouped because they run in sequence | A method that validates, transforms, then logs (always in that order) | Okay |
| Communicational | Elements operate on the same data | A class that reads a file and computes stats on its contents | Decent |
| Sequential | Output of one element is input to the next | A pipeline where parse() feeds validate() feeds transform() | Good |
| Functional | Every element contributes to a single well-defined task | TaxCalculator with only tax-related methods and fields | Best |
The goal is functional cohesion: every field and every method in the class exists to serve one purpose. If you can describe what the class does without using the word "and," it probably has functional cohesion.
The 'Util' class smell
A class named StringUtils, MiscHelper, or CommonUtils almost always has coincidental cohesion. The elements share a file because nobody knew where else to put them. When you see this, ask what the methods actually have in common. If the answer is "nothing," split them into the classes that actually use them.
The Goal: Low Coupling, High Cohesion
Low coupling and high cohesion are not two separate goals. They reinforce each other. When a class has high cohesion (everything in it is tightly related), it naturally needs fewer dependencies on other classes. When classes are loosely coupled (communicating through narrow interfaces), each one is free to be internally focused.
The top row shows two "god classes" that do everything and depend on each other's internals. The bottom row shows focused classes that communicate through interfaces. The bottom design is easier to test, easier to change, and easier to explain in an interview.
Here is the honest answer about when this matters most: small projects with one developer can tolerate moderate coupling. The cost kicks in when teams grow, features multiply, and someone other than you has to read your code. That is when tight coupling turns into a tax on every change.
Implementation: Before and After
Here is a concrete example. The "before" code has tight coupling (classes reach into each other) and low cohesion (classes mix unrelated concerns). The "after" code fixes both.
// β Low cohesion: this class does report generation, email sending,
// and database logging. Three unrelated concerns in one place.
// β Tight coupling: directly constructs dependencies, accesses
// global config, passes control flags.
public class ReportManager {
// Common coupling: reading shared mutable global state
private static final Map<String, String> GLOBAL_CONFIG = AppConfig.getSettings();
public void generateAndSend(String reportType, boolean skipEmail) {
// Concern 1: report generation
String data;
if (reportType.equals("sales")) {
data = querySalesData();
} else if (reportType.equals("inventory")) {
data = queryInventoryData();
} else {
data = "Unknown report type";
}
String report = formatAsHtml(data);
// Concern 2: email sending (control coupling via skipEmail flag)
if (!skipEmail) {
var emailClient = new SmtpEmailClient(
GLOBAL_CONFIG.get("smtp.host"),
Integer.parseInt(GLOBAL_CONFIG.get("smtp.port"))
);
emailClient.send(
GLOBAL_CONFIG.get("report.recipient"),
"Report: " + reportType,
report
);
}
// Concern 3: audit logging
try (var conn = DriverManager.getConnection(GLOBAL_CONFIG.get("db.url"))) {
var stmt = conn.prepareStatement(
"INSERT INTO audit_log (action, details) VALUES (?, ?)"
);
stmt.setString(1, "REPORT_GENERATED");
stmt.setString(2, reportType);
stmt.executeUpdate();
} catch (SQLException e) {
System.err.println("Audit log failed: " + e.getMessage());
}
}
private String querySalesData() { return "sales data..."; }
private String queryInventoryData() { return "inventory data..."; }
private String formatAsHtml(String data) { return "<html>" + data + "</html>"; }
}Notice what changed. The skipEmail boolean flag (control coupling) is gone. Global config access (common coupling) is gone, replaced by constructor injection of only the values each class needs. Each class has one concern (functional cohesion) instead of three concerns stuffed into one method.
The orchestrator's generateAndSend method is three lines. Each line calls one focused collaborator. You can test the ReportGenerator with a fake ReportDataSource and zero infrastructure. You can test AuditLogger without generating a report.
Measuring Coupling
You can quantify coupling during code reviews and design discussions. Two practical metrics stand out.
Fan-in is the number of modules that depend on a given module. High fan-in means the module is widely used (like a utility or interface). This is usually fine, as long as that module has a stable API.
Fan-out is the number of modules a given module depends on. High fan-out means the module reaches into many other modules to do its job. This is the warning signal. A class with 15 imports is probably doing too many things.
| Metric | What it measures | Healthy range | Red flag |
|---|---|---|---|
| Fan-in | How many modules depend on me | High is okay (stable, reusable) | Low fan-in on a "shared" class (nobody uses it) |
| Fan-out | How many modules I depend on | 3-7 dependencies | 10+ dependencies in a single class |
| Instability | Fan-out / (Fan-in + Fan-out) | Stable: 0.0, Unstable: 1.0 | Core domain classes with instability > 0.5 |
The instability ratio, introduced by Robert C. Martin, gives you a single number between 0 and 1. A module at 0.0 is maximally stable (many depend on it, it depends on few). A module at 1.0 is maximally unstable (it depends on many, few depend on it). You want your core business logic to be stable and your adapters (controllers, gateways) to be unstable.
My recommendation: run a quick dependency count on any class that "feels wrong" in a review. If fan-out exceeds 10, the class is a coupling magnet and needs to be split.
Interview tip: mention fan-in/fan-out
When an interviewer asks how you evaluate a design, say: "I look at coupling and cohesion. Specifically, I check fan-out on service classes. If a class imports more than 7-8 other modules, it is doing too much and I would split it." That is a concrete, measurable answer that stands out.
Common Mistakes
1. Coupling through shared mutable state
Two classes that both read and write to the same static HashMap or singleton cache are coupled even if they never import each other. This is common coupling, and it is invisible in import graphs. The symptoms show up at runtime: race conditions, stale reads, and changes in one class that silently break the other.
The fix: eliminate shared mutable state. If two modules need access to the same data, inject a shared service with a clear API, or use immutable data structures that one module produces and the other consumes.
2. The "Util" class with coincidental cohesion
StringUtils, MiscHelper, CommonFunctions. These names are symptoms. The class exists because someone needed a method and did not know where to put it. Six months later, the class has 40 methods that share nothing except their inability to find a better home.
The fix: move each method into the class that actually uses it. If formatCurrency is always called from pricing code, it belongs in the pricing package, not in a generic utility class. If multiple packages need it, create a focused CurrencyFormatter class rather than a dumping ground.
3. Hiding coupling behind a service locator
Some teams avoid constructor injection and instead use a ServiceLocator.get(ReportGenerator.class) pattern. The class signatures look clean (no dependencies in the constructor), but the runtime coupling is identical. Worse, it is hidden. You cannot see what a class depends on without reading through every method body.
Constructor injection is explicitly coupled but visibly so. You see every dependency in the constructor signature. That visibility is a feature, not a bug. When the constructor has 12 parameters, it is a loud signal that the class has too many responsibilities.
4. Over-decoupling with unnecessary interfaces
Creating an interface for every class (the IFoo / FooImpl pattern) does not reduce coupling if there is only one implementation and there will never be another. It adds indirection without benefit. The IDE now shows you two files instead of one, but the coupling between the caller and the implementation is unchanged.
Create interfaces when you genuinely need polymorphism: multiple implementations, test doubles for expensive resources, or a dependency inversion boundary between layers. If you have one implementation and no plans for a second, skip the interface.
When to add an interface
Use this test: "Would I ever want a second implementation?" If the answer is "yes, for testing" (databases, HTTP clients, file systems), add the interface. If the answer is "no, this is just internal logic," skip it. You can always extract an interface later when the need arises.
Test Your Understanding
Quick Recap
- Coupling measures dependencies between modules; cohesion measures focus within a module. The target is always low coupling with high cohesion.
- Coupling ranges from content coupling (worst, modifying another module's internals) to message coupling (best, async communication with no direct call).
- Cohesion ranges from coincidental (worst, random methods in a Util class) to functional (best, every element serves one purpose).
- Low coupling and high cohesion reinforce each other: focused modules naturally need fewer dependencies.
- Measure coupling with fan-in (who depends on me) and fan-out (who do I depend on). A fan-out above 10 is a red flag.
- The most common coupling mistake is shared mutable state, which creates invisible dependencies that do not appear in import graphs but cause runtime failures.
- The most common cohesion mistake is the "Util" class, which is a dumping ground of coincidental cohesion. Move each method to the class that owns that concern.