Two words you will hear in almost every design review.
- Coupling is how much one class depends on another. If a change in A often forces a change in B, they are tightly coupled. Coupling asks, “How much does this part need to know about that part?”
- Cohesion is how well the things inside one class belong together. A class with high cohesion has one clear job. Cohesion asks, “Do these things genuinely belong together?”
The goal is almost always stated as one line: low coupling, high cohesion. Each class does one clear job and knows as little as possible about the others.
A kitchen is a decent picture. A good kitchen keeps knives with knives and spices with spices (high cohesion). The chef does not need to know how the gas cylinder was delivered to cook dinner (low coupling).
Types of coupling, from worst to best
Not all coupling is equal. Some is unavoidable and harmless. Some will hurt you on every change. The classic list goes from tightest (worst) to loosest (best):
| Type | What it means | Tiny example |
|---|---|---|
| Content | One class reaches inside another class and changes its data | cart.items.clear() on a public field, or editing a private field through reflection |
| Common (global) | Classes share one global variable that any of them can change | AppState.currentCity as a static field read and written by five services |
| External | Classes depend on the same outside format, protocol or device | Three classes that each parse the payment gateway's raw webhook JSON themselves |
| Control | One class passes a flag that tells the other what to do | notifyCustomer(order, true) where true means "send SMS, not push" |
| Stamp | A whole object is passed when only one piece of it is used | sendOtp(customer) that only reads customer.getPhone() |
| Data | Only the simple values that are needed are passed | sendOtp(String phone, String otp) |
| Message | Classes talk only by sending messages or events, with no direct call | OrderService publishes OrderPlaced, the SMS sender listens |
External coupling is the one people forget. Say the payment gateway renames a field in its webhook (the message it sends to your server). If three classes each read that JSON, you fix three classes instead of one. The fix is to read the outside format in exactly one adapter class and hand everyone else your own PaymentEvent type.
The next two types are the ones you will see most in code reviews.
Control coupling is easy to miss. The caller makes a decision that really belongs to the callee. Here the boolean tells the method which path to take (order.phone() and order.deviceToken() return where to reach the customer):
void notifyCustomer(Order order, boolean useSms) {
if (useSms) smsClient.send(order.phone(), "Your order is on the way");
else pushClient.send(order.deviceToken(), "Your order is on the way");
}Every caller now has to know that there are two channels and how to pick one. A better design has two clear methods, or a Notifier interface with SmsNotifier and PushNotifier.
Stamp coupling looks harmless but spreads dependencies. This method needs only a phone number, yet it asks for the whole Customer:
String buildOtpMessage(Customer customer) {
return "Your Zomato OTP is " + otpFor(customer.getPhone());
}To test this you have to build a whole Customer. If Customer changes its constructor, this test breaks even though OTPs did not change. Pass the phone instead. (otpFor stands for any helper that builds the OTP and is not shown.)
Cohesion: high vs low
Cohesion is about one class. Ask: do all its methods work on the same data, toward the same goal?
Low cohesion, the classic "utils" class (a dumping ground for loose helper methods):
class OrderUtils {
static String formatPrice(BigDecimal amount) { /* ... */ }
static void sendOtp(String phone) { /* ... */ }
static int estimateDeliveryMinutes(double km) { /* ... */ }
static List<String[]> parseCsv(String path) { /* ... */ }
}Four unrelated jobs: price text, OTPs, delivery time and CSV files. Anyone changing CSV parsing has to open the same file as someone changing OTPs. Over time everything ends up in this class.
High cohesion:
class DeliveryEtaCalculator {
private final RiderLocator riderLocator;
private final TrafficService traffic;
int etaMinutes(Restaurant restaurant, Address drop) { /* ... */ }
int prepMinutes(Restaurant restaurant) { /* ... */ }
int travelMinutes(Location from, Location to) { /* ... */ }
}Everything here answers one question: how long until the food arrives? You can describe it in one sentence without the word "and".
Interviewers sometimes ask for the types of cohesion. The textbook list, from worst to best:
- Coincidental: random things put together, like
OrderUtils. - Logical: grouped by category, like "all validators".
- Temporal: things that run at the same time, like startup code.
- Procedural: steps that must run in order but share no data, like "check stock, then log the request".
- Communicational: work on the same data.
- Sequential: the output of one step is the input of the next.
- Functional: everything serves one task, like
DeliveryEtaCalculator.
You rarely need more than "coincidental is worst, functional is best".
Cohesion is basically the Single Responsibility Principle (a class should have one reason to change) seen from the inside of a class.
Why "low coupling, high cohesion" matters
- Changes stay local. Adding Paytm as a payment option touches the payment code, not the order, cart and rider code.
- Testing is easy. A class that depends on interfaces can be tested with fakes. No database, no network, no Spring context.
- Code is easier to read. You can understand one class without opening ten others.
- Teams can work in parallel. The payments team and the delivery team do not keep breaking each other's builds.
- Reuse becomes possible. A cohesive
DeliveryEtaCalculatorcan be used by the app, the restaurant dashboard and the support tool.
The two go together. A class with many unrelated jobs needs a dependency for each job, so low cohesion usually brings high coupling too.
How to measure it by feel
You do not need a metrics tool. These questions catch most problems:
- How many files change for one small feature? If adding a new coupon type means editing eight files across four packages, coupling is high. This smell is called shotgun surgery (one small change forces edits all over the place).
- Can you unit test the class without a database? If the only way to test
OrderServiceis to start Postgres, it is coupled to storage. - How many constructor parameters? As a rule of thumb, more than five or six usually means the class is doing too many jobs.
- Can you describe it in one sentence without "and"? "It calculates delivery ETA" is fine. "It places orders and sends SMS and updates loyalty points" is not.
- Does a change in one area break tests in an unrelated area? That is hidden coupling, often caused by shared global state.
- Does one class change for many different reasons? That is low cohesion, sometimes called divergent change (one class edited for many unrelated reasons).
Tools that reduce coupling
Interfaces. Depend on PaymentGateway, not RazorpayGateway. The caller knows only what the thing does, not who does it. See Interfaces.
Dependency injection. Do not create dependencies with new inside the class. Take them in the constructor, so the caller (or a framework) chooses which implementation to use. See Dependency Injection and DIP.
Events. Instead of OrderService calling SMS, loyalty and analytics directly, it publishes an OrderPlaced event and each of them listens. OrderService does not even know they exist. This idea is the Observer pattern and, at a larger scale, a message queue like Kafka.
Passing data, not objects. Pass the phone number, not the customer. Pass the city, not the order.
Worked example: a food delivery OrderService
Before: tightly coupled
class OrderService {
private final MySqlOrderRepository repo = new MySqlOrderRepository();
private final RazorpayClient razorpay = new RazorpayClient("rzp_live_key");
private final Msg91SmsClient sms = new Msg91SmsClient();
Order placeOrder(Customer customer, List<Item> items) {
BigDecimal total = items.stream()
.map(Item::price)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (AppConfig.festiveSale) {
total = total.multiply(new BigDecimal("0.85"));
}
razorpay.charge(customer.getUpiId(), total);
Order order = repo.insert(customer.getId(), items, total);
sms.send(customer.getPhone(), "Order #" + order.id() + " placed. Total ₹" + total);
LoyaltyService.INSTANCE.addPoints(customer.getId(), total.intValue() / 10);
return order;
}
}What is wrong here:
- It creates the MySQL, Razorpay and MSG91 clients itself with
new. You cannot swap any of them, and you cannot test without the real ones. AppConfig.festiveSaleis global state (common coupling). If another test turns it on, this test breaks.LoyaltyService.INSTANCEis a hidden dependency. Nothing in the constructor says this class needs it.placeOrdertakes a wholeCustomerbut reads only three getters (stamp coupling).- Pricing, payment, storage, SMS and loyalty all live in one method, so cohesion is low too.
After: loosely coupled
Each interface is a small contract: it says what must be done, not how. A record is Java's short form for a plain data class. The constructor receives the four contracts and creates nothing.
interface PaymentGateway { void charge(String upiId, BigDecimal amount); }
interface OrderRepository { Order save(Order order); }
interface PricingPolicy { BigDecimal totalFor(List<Item> items); }
interface EventPublisher { void publish(Object event); }
record OrderPlaced(long orderId, long customerId, BigDecimal total) {}
class OrderService {
private final PricingPolicy pricing;
private final PaymentGateway payments;
private final OrderRepository orders;
private final EventPublisher events;
OrderService(PricingPolicy pricing, PaymentGateway payments,
OrderRepository orders, EventPublisher events) {
this.pricing = pricing;
this.payments = payments;
this.orders = orders;
this.events = events;
}
Order placeOrder(long customerId, String upiId, List<Item> items) {
BigDecimal total = pricing.totalFor(items);
payments.charge(upiId, total);
Order order = orders.save(new Order(customerId, items, total));
events.publish(new OrderPlaced(order.id(), customerId, total));
return order;
}
}SMS and loyalty now listen for OrderPlaced on their own. OrderService knows only four small interfaces. The festive discount now lives inside a PricingPolicy implementation.
Order, Item, the test fakes, every EventPublisher implementation and the listeners are simple classes not shown. save returns the order with its id filled in.
This version focuses on coupling, so it skips two things a real order service needs. First, it charges the customer before saving the order. If the save fails, you have taken money for an order that does not exist. Real systems save the order as PENDING first, then charge with an idempotency key (a unique id that lets the gateway ignore a retried charge), then mark it PAID.
Second, saving to the database and publishing the event are two separate writes. If the process dies between them, SMS and loyalty never hear about the order. The usual fix is the outbox pattern: write the event into a table in the same transaction as the order (so both succeed or both fail), and let a separate job publish it.
Neither fix changes the coupling story, but an interviewer may ask about both next.
Solid arrows are direct dependencies. Dotted arrows point from each listener to the publisher it listens to. The publisher does not depend on the listeners.
And the test needs no database, no gateway and no SMS provider. It passes a lambda that always returns 450 for pricing, and simple fakes for the rest:
@Test
void chargesTheCustomerAndSavesTheOrder() {
var payments = new FakePaymentGateway();
var orders = new InMemoryOrderRepository();
var events = new RecordingPublisher();
var service = new OrderService(items -> new BigDecimal("450"), payments, orders, events);
service.placeOrder(42L, "rahul@okicici", List.of(new Item("Paneer Roll", new BigDecimal("450"))));
assertEquals(new BigDecimal("450"), payments.lastCharged());
assertEquals(1, orders.count());
assertInstanceOf(OrderPlaced.class, events.last());
}It checks that 450 was charged, one order was saved and an OrderPlaced event was published. If you can write a test like this in a few lines, coupling is under control.
Where people overdo it
Low coupling does not mean zero coupling. Classes have to talk to each other or nothing happens.
- Events everywhere. If every call becomes an event, nobody can follow the flow of a single order. Use events when the publisher truly should not care who listens (SMS, analytics, loyalty). Keep direct calls when the caller needs the result right away (payment).
- An interface for every class.
PriceFormatterwith one implementation that will never change does not need an interface. - Splitting too small. Ten classes of five lines each, all calling each other, is not high cohesion. It is the same tangle spread over more files.
Bonus: connascence
Connascence is a finer way to talk about coupling. Two pieces of code are connascent if changing one forces a change in the other. The weaker kinds are connascence of name (you call placeOrder, so renaming it breaks you) and of type (both sides must agree on a data type). Stronger kinds include connascence of position (new Order(42, 450, "UPI") breaks if someone swaps two arguments) and of meaning (both sides must agree that status 3 means "delivered"). The rule is to turn strong forms into weak ones. Use a builder or named record fields instead of position. Use an enum instead of magic numbers (raw values with no name).
Related
- Class Relationships for the kinds of links between classes.
- Composition vs Inheritance, since inheritance is one of the tightest forms of coupling.
- Beyond SOLID: DRY, KISS, YAGNI and Friends for the Law of Demeter, which is a coupling rule in disguise.
Interview questions
"What is the difference between coupling and cohesion?"
Coupling is how much one class depends on others. Cohesion is how well the parts inside one class belong together. You want low coupling and high cohesion.
"Name the types of coupling from worst to best."
Content, common (global state), external (a shared outside format), control, stamp, data and message. Content coupling reaches into another class's internals. Message coupling uses only messages or events.
"What is control coupling? How do you remove it?"
A caller passes a flag that tells the callee which path to take, like
notifyCustomer(order, true)for SMS. The caller ends up knowing the callee's internals. Replace it with separate methods or with polymorphism, like aNotifierinterface with SMS and push implementations.
"How can you tell a class has low cohesion?"
You cannot describe it in one sentence without "and", its methods use different fields, and it changes for many unrelated reasons.
OrderUtilsis the usual example.
"How does dependency injection reduce coupling?"
The class receives its dependencies, usually as interfaces, through the constructor instead of creating them. It does not know which concrete class it uses, so you can swap implementations or pass fakes in tests.
"How would you check if a codebase is tightly coupled?"
Count how many files change for one small feature, and check whether you can unit test a class without a database or network. Long constructors, global state and tests that break in unrelated areas are other signs.
"Can you have too little coupling?"
Yes. Turning every call into an event or wrapping every class in an interface makes the flow hard to follow and adds code for no benefit. Some coupling is needed for the system to work. Keep it to simple, stable interfaces.