Cohesion & Coupling

Intermediate · 11 min read · ▶ live playground · ✦ checkpoint

A sales-tax change should be one focused edit. Instead, the search results send you through the checkout module, refund calculator, receipt formatter, reporting mapper, and test fixtures—five files for one rule.

That change pattern is shotgun surgery: one logical change forces small, coordinated edits across many places. Two design qualities explain why it happens. Cohesion is how strongly the parts inside one module belong together. Coupling is how much one module must know about another module to do its work.

The usual goal is high cohesion, where related data and behavior change as one unit, and low coupling, where modules meet through small, stable expectations. They are directions, not scores you can maximize independently. Moving a responsibility to its rightful home often improves both at once.

Before: unrelated work gathers in one module

OrderOperations began as a place to calculate a total. Saving orders and emailing receipts seemed close enough, so those jobs landed there too. The class now changes for pricing, database, and email decisions, and it constructs the concrete tool for each one.

class diagram

Before: one module knows every operational detail

Before: one module knows every operational detail
  1. 1OrderOperations mixes pricing, storage, and receipt work inside one public surface.
  2. 2It owns a concrete TaxTable, so tax policy and order workflow change together.
  3. 3It also chooses SQL storage and must understand that implementation's setup.
  4. 4SMTP adds a third reason to edit and a third concrete dependency to configure.
  5. 5Low cohesion inside the module creates high coupling to three unrelated decisions.

The filled diamonds reflect what the code does: OrderOperations creates and owns each concrete collaborator. Replacing SMTP with an HTTP mail provider is no longer an email-only change because the order module names SmtpReceiptSender and decides how to construct it.

type Region = "local" | "remote";
 
type OrderLine = {
  unitPrice: number;
  quantity: number;
};
 
class TaxTable {
  public rateFor(region: Region): number {
    return region === "local" ? 0.08 : 0.12;
  }
}
 
class SqlOrderStore {
  private records = new Map<string, number>();
 
  public save(orderId: string, total: number): void {
    this.records.set(orderId, total);
  }
}
 
class SmtpReceiptSender {
  private sent: string[] = [];
 
  public send(email: string, body: string): void {
    this.sent.push(`${email}|${body}`);
  }
}
 
class OrderOperations {
  // smell: constructing concrete tools couples this module to their setup.
  private tax = new TaxTable();
  private store = new SqlOrderStore();
  private receipts = new SmtpReceiptSender();
 
  // smell: unrelated concerns share a module
  public total(lines: OrderLine[], region: Region): number {
    const subtotal = lines.reduce(
      (sum, line) => sum + line.unitPrice * line.quantity,
      0,
    );
    return subtotal * (1 + this.tax.rateFor(region));
  }
 
  public save(orderId: string, total: number): void {
    this.store.save(orderId, total);
  }
 
  public sendReceipt(email: string, total: number): void {
    this.receipts.send(email, `Paid $${total.toFixed(2)}`);
  }
}

The three fields are not automatically a problem. The smell comes from the decisions attached to them. A tax rule, a schema migration, and an email-provider change come from different pressures, yet all reopen OrderOperations. Its concrete new expressions also make a test accept SQL and SMTP choices even when the test only cares about order flow.

After: regroup the work around change

The refactor gives order mathematics to Order, storage to an OrderRepository, and delivery to a ReceiptSender. CheckoutService keeps the small coordination job: store this order, then send its receipt.

class diagram

After: cohesive modules joined by narrow seams

After: cohesive modules joined by narrow seams
  1. 1Order keeps the state and calculation rules that make an order total valid.
  2. 2OrderRepository exposes one storage operation without exposing a database choice.
  3. 3ReceiptSender offers one delivery operation without leaking SMTP configuration.
  4. 4CheckoutService temporarily uses the order passed to place().
  5. 5A narrow repository reference gives the workflow only the storage behavior it needs.
  6. 6A second narrow reference keeps receipt delivery independent from order calculation.

The plain lines say CheckoutService keeps references to two contracts. The dashed open arrow says it uses the Order supplied to place() for that call. The service is still coupled, but only to operations its workflow needs.

type Region = "local" | "remote";
 
type OrderLine = {
  unitPrice: number;
  quantity: number;
};
 
class Order {
  private id: string;
  private email: string;
  private lines: OrderLine[];
  private region: Region;
 
  public constructor(
    id: string,
    email: string,
    lines: OrderLine[],
    region: Region,
  ) {
    this.id = id;
    this.email = email;
    this.lines = lines;
    this.region = region;
  }
 
  public number(): string {
    return this.id;
  }
 
  public customerEmail(): string {
    return this.email;
  }
 
  public total(): number {
    const subtotal = this.lines.reduce(
      (sum, line) => sum + line.unitPrice * line.quantity,
      0,
    );
    const taxRate = this.region === "local" ? 0.08 : 0.12;
    return subtotal * (1 + taxRate);
  }
}
 
interface OrderRepository {
  save(order: Order): void;
}
 
interface ReceiptSender {
  send(order: Order): void;
}
 
class MemoryOrderRepository implements OrderRepository {
  private records = new Map<string, Order>();
 
  public save(order: Order): void {
    this.records.set(order.number(), order);
  }
}
 
class ConsoleReceiptSender implements ReceiptSender {
  public send(order: Order): void {
    console.log(`Receipt ${order.number()}: $${order.total().toFixed(2)}`);
  }
}
 
class CheckoutService {
  private orders: OrderRepository;
  private receipts: ReceiptSender;
 
  public constructor(
    orders: OrderRepository,
    receipts: ReceiptSender,
  ) {
    this.orders = orders;
    this.receipts = receipts;
  }
 
  public place(order: Order): void {
    this.orders.save(order);
    this.receipts.send(order);
  }
}
 
const order = new Order(
  "ORD-42",
  "ada@example.com",
  [{ unitPrice: 10, quantity: 2 }, { unitPrice: 5, quantity: 1 }],
  "local",
);
 
const checkout = new CheckoutService(
  new MemoryOrderRepository(),
  new ConsoleReceiptSender(),
);
 
checkout.place(order);

Receipt ORD-42: $27.00

Now a storage change belongs in a repository implementation. A delivery change belongs in a sender implementation. CheckoutService changes only when the checkout sequence changes, while Order changes when order calculation changes. The boundaries follow the change pressures.

When not to chase zero coupling

Zero coupling is impossible in a useful program. An order workflow must call something that stores an order, and the caller must know that place() exists. Low coupling means keeping that knowledge intentional and narrow, not erasing every relationship.

Over-decoupling is adding indirection that does not protect an independent change. Wrapping a stable three-line calculation in an interface, factory, adapter, and registry adds names and navigation without separating a real pressure. The result can be harder to read than one direct call.

Keep a concrete dependency when it is local, stable, and cheaper to replace by editing the caller than by maintaining an abstraction. Introduce a seam when implementations vary, setup leaks into unrelated code, or two modules genuinely change for different reasons. Decouple what actually changes independently.

Checkpoint

Answer all three to mark this lesson complete

You can now place related work together and keep cross-module knowledge narrow. Next, DRY, KISS, and YAGNI help decide whether a shared abstraction is useful—or just more machinery.

+50 XP on completion