Software Design Principles

High Cohesion, Loose Coupling

Master the fundamental principles that separate maintainable, scalable software from tangled spaghetti code. Learn how to build systems that embrace change.

📅 Originally published 2017 • Modernized & redesigned for 2024⏱️ 12 min read🎯 Intermediate

Why This Matters More Than Ever

We live in a time where businesses change their minds constantly. And they're right to do so—it gives them a competitive edge. Your job as a software engineer is to build systems that can adapt to this change without falling apart.

Here's the truth: most software development effort happens after the initial release. If you want your software to succeed long-term, you must design it to respond easily to change.

There is no silver bullet in software development.

But there are fundamental principles that consistently lead to better outcomes.

High Cohesion: Keep Related Things Together

Cohesion measures how strongly related the responsibilities of a single module (class, function, service) are. High cohesion means everything in the module works together toward a single, well-defined purpose.

Cohesion Visualized

Low Cohesion Class

Methods barely use the class fields - unrelated functionality grouped together

UtilityClass
elementA
elementB
elementC
methodA()
uses elementA only
methodB()
uses nothing
methodC()
uses elementB only

Key Insight: In low cohesion classes, methods operate independently and could be split into separate classes. This makes the code harder to maintain and understand.

Types of Cohesion: From Worst to Best

WorstBest

Functional Cohesion

Level 7/7

Parts grouped because they all contribute to a single, well-defined task. The BEST type of cohesion!

Example:
class EmailValidator {
  private email: string;
  
  isValidFormat() { }
  hasValidDomain() { }
  checkDNSRecords() { }
  validate() { }
}

Cohesion in TypeScript Classes

Low Cohesion - Utility Mess

class UtilityClass {
  private elementA: number;
  private elementB: number;
  private elementC: string;

  // This method only uses elementA
  calculateTax(): number {
    return this.elementA * 0.15;
  }

  // This method only uses elementB
  formatCurrency(): string {
    return `$${this.elementB}`;
  }

  // This method doesn't use any fields!
  sendEmail(to: string, subject: string): void {
    // Email logic...
  }

  // Another unrelated method
  parseXML(xml: string): object {
    // XML parsing...
  }
}

High Cohesion - Single Responsibility

// High cohesion: All methods work with user account data
class UserAccount {
  private username: string;
  private email: string;
  private balance: number;

  // All methods use the class fields
  updateProfile(name: string, email: string): void {
    this.username = name;
    this.email = email;
    this.notifyProfileUpdate();
  }

  sendNotification(message: string): void {
    // Uses email field
    emailService.send(this.email, message);
  }

  processPayment(amount: number): boolean {
    // Uses balance and email
    if (this.balance >= amount) {
      this.balance -= amount;
      this.sendNotification(`Payment of $${amount} processed`);
      return true;
    }
    return false;
  }

  private notifyProfileUpdate(): void {
    this.sendNotification('Profile updated successfully');
  }
}

The good example shows high cohesion: all methods are focused on managing a user account and use the class fields. The bad example has low cohesion: methods are unrelated and barely use the class state.

🎯 How to Improve Cohesion:

  • Split utility classes - If methods don't share state, they shouldn't share a class
  • Extract hidden objects - If some methods only use subset of fields, extract a new class
  • Single Responsibility Principle - Each class should have one reason to change
  • Use metrics - Tools like SonarQube (LCOM4) can identify low cohesion

Loose Coupling: Minimize Dependencies

Coupling measures how much one component knows about the inner workings of another. Loose coupling means components depend on each other to the least extent possible—they communicate through well-defined interfaces, not implementation details.

💡 The iPod Analogy: iPods are tightly coupled—when the battery dies, you might as well buy a new one because the battery is soldered in. A loosely coupled music player would let you easily swap the battery. Same principle applies to software.

Coupling Visualized

Tight Coupling

ClassA directly depends on ClassB's implementation details

ClassA
method() {
const b = new ClassB()
return b.data
}
Direct dependency
ClassB
public data
method() {...}
⚠️ Changing ClassB breaks ClassA!

Key Insight: Tight coupling makes your code rigid and hard to change. Any modification to ClassB requires changes to ClassA, creating a maintenance nightmare.

Coupling in TypeScript

Tight Coupling

// Bad: ClassA directly creates and depends on ClassB
class ClassA {
  private elementA: boolean;

  methodA(): number {
    if (this.elementA) {
      // Directly instantiating ClassB - tight coupling!
      return new ClassB().elementB;
    }
    return 0;
  }

  printValues(): void {
    // Creating another instance - tightly coupled
    new ClassB().methodB();
  }
}

class ClassB {
  public elementB: number = 42;

  methodB(): void {
    console.log(this.elementB);
  }
}

// What if ClassB constructor changes?
// What if we need a different implementation?
// ClassA breaks!

Loose Coupling via Dependency Injection

// Good: ClassA depends on interface, not implementation
interface IService {
  getData(): number;
  printData(): void;
}

class ClassA {
  // Dependency injected through constructor
  constructor(private service: IService) {}

  methodA(): number {
    // Uses the interface, not the concrete class
    return this.service.getData();
  }

  printValues(): void {
    this.service.printData();
  }
}

// Implementation can change without affecting ClassA
class ClassB implements IService {
  private elementB: number = 42;

  getData(): number {
    return this.elementB;
  }

  printData(): void {
    console.log(this.elementB);
  }
}

// Easy to swap implementations!
class MockService implements IService {
  getData(): number { return 100; }
  printData(): void { console.log('Mock!'); }
}

// Usage - dependency injection
const service = new ClassB();
const classA = new ClassA(service);  // Loose coupling!

// For testing
const mockService = new MockService();
const testClassA = new ClassA(mockService);  // Same interface!

Loose coupling through interfaces enables you to swap implementations without changing dependent code. This makes testing easier, refactoring safer, and your code more flexible.

🎯 How to Reduce Coupling:

  • Depend on abstractions - Use interfaces/types, not concrete classes
  • Dependency Injection - Pass dependencies in rather than creating them
  • Event-driven communication - Components react to events, not direct calls
  • API contracts - Define stable interfaces, hide implementation

The Law of Demeter

The Law of Demeter (LoD), also known as the Principle of Least Knowledge, is a special case of loose coupling. It can be summarized in one sentence:

"Don't Talk to Strangers"

Or more simply: "Use only one dot"

Law of Demeter: "Don't Talk to Strangers"

Also known as the Principle of Least Knowledge. A simple rule: "Use only one dot"

Violating the Law

// TypeScript - Too many dots!
class DogWalker {
  walkDog(dog: Dog) {
    // Reaching through multiple objects
    dog.getBody().getLegs().walk();
    //  ^       ^         ^      ^ = 4 method calls!

    // Even worse - chain of dependencies
    const speed = dog
      .getOwner()
      .getHouse()
      .getAddress()
      .getCity()
      .getTrafficLevel();
  }
}

Problem: DogWalker knows too much about Dog's internals. If the internal structure changes, this breaks! You're "talking to strangers" (Body, Legs, Owner, House, etc.)

🎯 Analogy: Imagine asking your friend for their wallet, then opening it yourself to take money out. Awkward! Instead, you'd just ask them for the money.

A method may only call methods of:
  • The object itself (this.someMethod())
  • Objects passed as arguments
  • Objects it creates locally
  • Its direct properties/fields

Modern Real-World Examples

These principles aren't just theory—they're essential in modern software architecture. Let's see how they apply to React, microservices, and API design.

Modern Examples: High Cohesion & Loose Coupling in 2024

React Components: Cohesion & Coupling

Low Cohesion, Tight Coupling
// Bad: "God Component" doing everything
function UserDashboard() {
  // Handles auth, data fetching, UI, analytics...
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [analytics, setAnalytics] = useState({});

  useEffect(() => {
    // Direct database calls
    fetch('/api/user').then(r => r.json()).then(setUser);
    fetch('/api/posts').then(r => r.json()).then(setPosts);
    fetch('/api/analytics').then(r => r.json()).then(setAnalytics);

    // Analytics tracking
    window.gtag('event', 'page_view');
  }, []);

  return (
    <div>
      {/* Mixing concerns: data, UI, business logic */}
      <h1>{user?.name}</h1>
      {posts.map(post => (
        <div onClick={() => {
          // Business logic in UI
          fetch(`/api/posts/${post.id}/like`, {method: 'POST'});
          window.gtag('event', 'like', {post_id: post.id});
        }}>
          {post.title}
        </div>
      ))}
    </div>
  );
}
High Cohesion, Loose Coupling
// Good: Separated concerns with custom hooks & services
// Custom hook - high cohesion (all about user data)
function useUser() {
  const [user, setUser] = useState(null);
  useEffect(() => {
    userService.getCurrentUser().then(setUser);
  }, []);
  return user;
}

// Service layer - loose coupling via interfaces
interface IPostService {
  getPosts(): Promise<Post[]>;
  likePost(id: string): Promise<void>;
}

// Component - focused only on UI
function UserDashboard() {
  const user = useUser();
  const { posts, likePost } = usePosts();
  const analytics = useAnalytics();

  return (
    <div>
      <UserHeader user={user} />
      <PostList posts={posts} onLike={likePost} />
      <AnalyticsWidget data={analytics} />
    </div>
  );
}

// Each component has ONE job
function PostList({ posts, onLike }: Props) {
  return posts.map(post => (
    <PostCard key={post.id} post={post} onLike={onLike} />
  ));
}

Key improvements: Custom hooks for data logic, service layer for loose coupling, components focused on single responsibilities.

The Path to Maintainable Software

Building maintainable software is part science, part art. You need to predict the future—where will changes come from? But you don't need to predict perfectly.

Key Takeaways

  • High cohesion keeps related functionality together, making code easier to understand and maintain
  • Loose coupling minimizes dependencies, making code easier to change and test
  • Law of Demeter prevents reaching through objects, reducing brittleness
  • These principles scale from functions to microservices—they're fundamental

When components are loosely coupled and highly cohesive, you can:

  • Change implementations without breaking dependents
  • Test components in isolation
  • Develop and deploy independently
  • Scale teams and systems effectively
  • Respond to business changes quickly

"The only thing that separates good software from bad is the value it provides to the business at any particular point in time. To prolong that value, make your software respond easily to change."