Coupling & Cohesion
Stage 3 · Architecture & System Design · B.U.I.L.D. letter: U
You built it fast, it works, and you're proud — then a week later you change the user's profile picture and somehow the checkout flow breaks. That's not bad luck. That's coupling, and it's trying to tell you something.
⚠️ The vibe trap
Vibe coding gets you to working software faster than almost anything else. The trap is that "working" and "maintainable" are two completely different things. When you wire components together by feel — passing objects around, reading internal state, mutating shared variables — the code starts growing invisible threads between every part of it. Change one thing, ten unrelated things break. The codebase stops being something you shape and starts being something you negotiate with.
🔗 What coupling actually is
Coupling measures how much one module knows about or depends on the internals of another. Low coupling means a module only cares about a narrow, explicit contract with the outside world. High coupling means it reaches in, grabs state, calls private methods, and assumes it knows how the other module works inside.
The classic tight-coupling smell:
// ❌ Tightly coupled — CartService reaches INTO UserService's internals
class CartService {
addItem(item) {
// Directly accesses another module's internal state object
const user = UserService.currentUser; // grabbed raw internal state
const discount = user._loyaltyPoints * 0.01; // knows the internal formula
user._cartItems.push(item); // mutates another module's data
UserService._recalcTotals(); // calls a "private" method
}
}
Mental model: Coupling is the number of assumptions baked into your code about how something else works. Every underscore-prefix access, every direct object mutation, every import { someInternalHelper } from a module that never exported it cleanly — that's an assumption. Assumptions are debt.
Why it matters: CartService and UserService are now a single unit in disguise. You can't test, deploy, refactor, or even read one without the other. A junior dev renaming _loyaltyPoints to loyaltyScore breaks the cart — and there's nothing in the code to tell them that.
Common mistake: Thinking that putting code in separate files means it's decoupled. Files are organizational, not architectural. Coupling is about what your code knows, not where it lives.
🎯 What cohesion actually is
Cohesion measures how focused a module is on a single, well-defined job. A highly cohesive module does one thing, and every function/method/export inside it serves that one purpose. A low-cohesion module is a junk drawer.
The junk-drawer smell:
// ❌ Low cohesion — "utils.js" that does everything
export function formatDate(d) { ... }
export function validateEmail(e) { ... }
export function calcShippingCost(weight, zone) { ... }
export function renderUserAvatar(user) { ... }
export function hashPassword(pw) { ... }
export function parseCSV(str) { ... }
High cohesion alternative — split by actual domain responsibility:
// ✅ date-utils.js — only date formatting
export function formatDate(d) { ... }
export function formatRelativeTime(d) { ... }
// ✅ auth-utils.js — only authentication helpers
export function hashPassword(pw) { ... }
export function validateEmail(e) { ... }
// ✅ shipping.js — only shipping calculations
export function calcShippingCost(weight, zone) { ... }
export function getAvailableZones() { ... }
Mental model: Ask "if I described this module in one sentence without using the word 'and', could I?" If you can't, it has cohesion problems. A file named utils.js that has grown to 800 lines is almost always a cohesion debt collector.
Why it matters: Low cohesion means a change to email validation can break shipping calculations — not because they're actually related, but because they live together. It also makes code nearly impossible to test in isolation.
Common mistake: Creating a utils.js or helpers.js file as the first file in a project and then never stopping adding to it. Shared utilities are fine; undifferentiated shared utilities are a smell.
💉 Loosening coupling with interfaces and dependency injection
The move from tight to loose coupling almost always involves two techniques working together: define a contract (interface or expected shape), and inject the dependency instead of importing it directly.
Before — hard dependency, tightly coupled:
// ❌ CartService imports and calls EmailService directly
import { EmailService } from './email-service.js';
class CartService {
async checkout(cart) {
const order = await this.processPayment(cart);
// CartService now DEPENDS on EmailService's implementation details
await EmailService.sendTransactionalEmail({
template: 'order-confirmation',
to: cart.user.email,
data: order,
});
return order;
}
}
After — dependency injected, loosely coupled:
// ✅ CartService receives a notifier — it doesn't care HOW notifications work
class CartService {
// The notifier is injected: could be email, SMS, push, or a test spy
constructor(notifier) {
this.notifier = notifier;
}
async checkout(cart) {
const order = await this.processPayment(cart);
// CartService only knows the contract: notifier.send(userId, event, data)
await this.notifier.send(cart.user.id, 'order-confirmed', order);
return order;
}
}
// Wiring it up in the application entry point (not inside CartService)
const cartService = new CartService(new EmailNotifier());
// In tests, swap with a fake — zero real emails sent, zero external calls
const cartService = new CartService(new FakeNotifier());
Mental model: Dependency injection means "tell me who to call; don't make me go find them." The module defines the shape of what it needs (a notifier with a .send() method). Whoever builds the module decides which real implementation plugs in. This is the "D" in SOLID (coming up in Lesson 4) and the foundation of testable architecture.
Why it matters: With DI, swapping EmailNotifier for SMSNotifier is a one-line change in one place. Without it, you're grepping the entire codebase for EmailService imports and hoping you caught them all.
Common mistake: Injecting the dependency but still importing a concrete class as the fallback default inside the constructor. That defeats the whole purpose — the constructor should not contain import statements.
📡 Events as the ultimate decoupler
Sometimes two modules genuinely need to communicate, but you don't want either of them to know the other exists. That's what events are for.
// ✅ Event-driven: UserService and AnalyticsService are completely unaware of each other
// user-service.js
import { eventBus } from './event-bus.js';
class UserService {
async updateProfile(userId, data) {
const user = await db.users.update(userId, data);
// Fires an event — UserService does NOT know who listens
eventBus.emit('user:profile-updated', { userId, changes: data });
return user;
}
}
// analytics-service.js
import { eventBus } from './event-bus.js';
eventBus.on('user:profile-updated', ({ userId, changes }) => {
analytics.track(userId, 'profile_updated', changes);
});
// Neither module imports the other. Adding a third listener (notifications,
// audit-log, A/B test tracker) is zero changes to UserService.
Mental model: Events flip the dependency arrow. Instead of UserService calling AnalyticsService, UserService announces what happened and anyone who cares can listen. Adding a new listener never touches the emitter.
Why it matters: This is how large systems stay maintainable as they grow. New features subscribe to existing events rather than stitching into existing code. The change is additive, not invasive.
Common mistake: Using events for everything including synchronous workflows where you need the result immediately. Events are for "I'm done, anyone who cares should know" — not for "I need an answer back right now." Overusing events makes execution order invisible and debugging painful.
The 1-file edit vs. the 20-file nightmare
Here is the real cost of coupling, expressed as a rule:
- Low coupling = a business rule change touches 1–3 files, all of which are obviously related.
- High coupling = a business rule change triggers a cascade through unrelated modules, each of which imports from the last, until you've touched 20 files to change one behavior.
When your PR changes 20 files for a feature that conceptually lives in one place, coupling is the culprit. The fix is almost never "be more careful" — it's to introduce a boundary (interface, event, injected dependency) that stops the cascade cold.
🛠️ Your mission
Open a project you've already built — a vibe-coded app, a side project, anything with more than two files.
- Find one place where a module reaches directly into another module's data or calls an internal method (tight coupling). The easiest tells: accessing properties on an imported object, mutating state that "belongs" to another module, or a
utils.jsfile that imports from 10 different places. - Design a thin contract — a function signature, a method shape, or an event name — that the caller only needs to know about, not the implementation.
- Refactor the caller to use the contract instead of the internals. The callee's internals should be able to change completely without the caller caring.
- If you can, write a quick test that swaps in a fake implementation to prove the boundary is real.
You don't need to refactor the whole codebase. One clean boundary, done properly, teaches you more than a hundred blog posts.
✅ You're done when…
- You can point to a before and after in your code where coupling was removed — and explain in one sentence what the new contract is.
- Your refactored module can be tested without instantiating or importing any of its former tight dependencies (use a fake/stub instead).
- You've run your change against the Code Review Rubric and confirmed: no module reads private state from another, no shared mutable objects cross module boundaries, and each module has a single clearly-stateable responsibility.
- You can answer: "If I replaced the injected dependency with a completely different implementation, how many files would change?" — and the answer is one.
- Your
utils.js(if you have one) has been split, renamed, or its contents assigned to a module that actually owns them.
➡️ Next: Design Patterns That Matter.
Build It Right, Or Don't Build It At All. 🏛️