Introduction

Ever stared at a massive class file with hundreds of lines of code and thought, “What is this thing even supposed to do?” I sure have. That confusion usually points to problems with cohesion and coupling—two concepts that might sound academic but actually make the difference between code you want to work with and code you want to run away from.

Let’s talk about these two fundamental ideas that can transform your code from a tangled mess into something you’re actually proud of.

Cohesion: The Power of Focus

Think of cohesion as the “teamwork score” within a single class. It answers a simple question: “Is this class doing exactly one job and doing it well?”

I like to explain cohesion using the Swiss Army knife versus specialized tools analogy. A Swiss Army knife does many things okay, but a dedicated screwdriver does one thing really well. In coding, we typically want our classes to be specialized tools, not Swiss Army knives.

High Cohesion Example

Here’s a class that nails high cohesion in C#:

public class Customer
{
  private readonly string name;
  private readonly string email;

  public Customer(string name, string email)
  {
      this.name = name;
      this.email = email;
  }

  public string GetName()
  {
      return name;
  }

  public string GetEmail()
  {
      return email;
  }

  public void UpdateEmail(string newEmail)
  {
      // Email could include validation logic here
      email = newEmail;
  }
}

Notice how this Customer class is laser-focused on one thing? It’s just handling customer identity info, a name and an email. There’s no weird method for calculating shipping costs or sending marketing emails. It does one job and does it well.

You can tell this class has high cohesion because:

  • It sticks to one clear job: tracking who the customer is
  • All its methods directly use or change the customer’s core data
  • It doesn’t try to save itself to a database or validate business rules

Low Cohesion Example

Now check out this mess of a class, we’ve all written something like this at some point:

class Student {
  name: string;
  grade: number;
  isEnrolled: boolean;

  constructor(name: string, grade: number) {
    this.name = name;
    this.grade = grade;
    this.isEnrolled = false;
  }

  enroll() {
    this.isEnrolled = true;
    // Could also notify the enrollment system
  }

  getGPA(): number {
    return this.grade / 100; // Converts raw grade to GPA scale
  }

  save(): void {
    // Save student to database
    console.log(`Saving ${this.name} to the database...`);
    // Database code would go here
  }

  sendEmail(): void {
    // Send email to student
    console.log(`Sending email to ${this.name}...`);
    // Email sending code would go here
  }
}

This Student class is like that coworker who tries to do everyone’s job. It’s handling student info, but also doing database work and sending emails. It’s a classic kitchen sink class!

What makes this code so problematic?

  • It’s trying to juggle way too many responsibilities:
    1. Keeping track of who the student is
    2. Managing enrollment
    3. Calculating grades
    4. Dealing with database stuff
    5. Handling communications
  • When your email system changes, you’ll need to modify this class
  • Good luck trying to write a unit test for this monster

How I’d Fix That Student Class

Here’s how I’d break down that unfocused Student class into something more manageable:

// Just handles student information -> nothing else!
class Student {
  constructor(
    public readonly name: string,
    private grade: number,
    private isEnrolled: boolean = false
  ) {}

  getGPA(): number {
    return this.grade / 100; // Simple grade calculation
  }

  enroll(): void {
    this.isEnrolled = true; // Just flips a flag, no side effects
  }

  isCurrentlyEnrolled(): boolean {
    return this.isEnrolled;
  }
}

// All database stuff goes here
class StudentRepository {
  saveStudent(student: Student): void {
    // All the database access code lives here
    console.log(`Saving ${student.name} to the database...`);
  }

  findStudentByName(name: string): Student {
    // Database lookup code here
    console.log(`Finding student ${name}...`);
    return null;
  }
}

// Communication is its own concern
class StudentNotifier {
  sendEmailToStudent(student: Student, message: string): void {
    // All email logic is contained here
    console.log(`Sending "${message}" to ${student.name}...`);
  }
}

See how much cleaner that is? Now when the email system changes, you only update the StudentNotifier. When the database changes, only the StudentRepository needs modification. Each class has one job, and it does it well.

Coupling: The Art of Keeping Your Distance

If cohesion is about focus within a class, coupling is about relationships between classes. It boils down to, “How entangled is this class with other classes?” or more practically, “If I change this one class, how many others will break?”

I think of coupling like relationships between people. High coupling is like those friends who can’t do anything without checking with each other first. Low coupling is more like casual acquaintances who respect each other’s independence. In code, we want more of the second type!

High Coupling Example

Here’s an example of high coupling in C#:

public class Order
{
  private readonly Product product;
  private int quantity;

  public Order(Product product, int quantity)
  {
      this.product = product;
      this.quantity = quantity;
  }

  public decimal CalculateTotal()
  {
      // Direct dependency on Product implementation
      return product.Price * quantity;
  }

  public void Ship()
  {
      // Direct dependency on shipping implementation
      var shipper = new UPSShipper();
      shipper.Ship(this);

      // Direct dependency on notification system
      EmailService.SendOrderConfirmation(this);
  }
}

This Order class shows high coupling because:

  • It depends directly on the concrete Product class implementation
  • It creates and uses a specific UPSShipper class
  • It directly calls a static EmailService class
  • Any changes to these dependencies would require changes to the Order class
  • Testing is difficult because we can’t easily substitute mock implementations

Low Coupling Example

Here’s a better approach with low coupling:

public class OrderProcessor
{
    private readonly IShippingProvider shippingProvider;
    private readonly INotificationService notificationService;

    // Dependencies injected through constructor
    public OrderProcessor(
        IShippingProvider shippingProvider,
        INotificationService notificationService)
    {
        this.shippingProvider = shippingProvider;
        this.notificationService = notificationService;
    }

    public void ProcessOrder(Order order)
    {
        // Process the order...

        // Use abstractions instead of concrete implementations
        shippingProvider.ShipOrder(order);
        notificationService.NotifyCustomer(order);
    }
}

public interface IShippingProvider
{
    void ShipOrder(Order order);
}

// Concrete implementations
public class UPSShippingProvider : IShippingProvider
{
    public void ShipOrder(Order order)
    {
        // Ship the order using UPS...
    }
}

public class FedExShippingProvider : IShippingProvider
{
    public void ShipOrder(Order order)
    {
        // Ship the order using FedEx...
    }
}

public interface INotificationService
{
    void NotifyCustomer(Order order);
}

public class EmailNotificationService : INotificationService
{
    public void NotifyCustomer(Order order)
    {
        // Send notification via email...
    }
}

This implementation demonstrates low coupling because:

  • OrderProcessor depends on abstractions (interfaces) rather than concrete implementations
  • Dependencies are injected rather than created inside the class
  • We can easily substitute different implementations without changing OrderProcessor
  • Changes to shipping or notification implementations won’t affect OrderProcessor
  • Testing is simpler as we can provide mock implementations

Benefits of High Cohesion and Low Coupling

Designing systems with high cohesion and low coupling provides numerous advantages:

Benefits of High Cohesion

  • Easier to understand: When a teammate asks “what does this class do?” you can answer in one sentence
  • Simpler to maintain: You can fix a bug without accidentally breaking three other features
  • More reusable: That focused component can slot right into your next project
  • Easier to test: You don’t need complex setup just to test one small feature
  • Less complex: Each class solves one problem really well instead of ten problems poorly

Benefits of Low Coupling

  • Swap components easily: Like changing a light bulb without rewiring the house
  • Multiple teams can work together: Team A doesn’t break Team B’s code constantly
  • Systems can grow: Add new features without massive rewrites of existing code
  • Find bugs faster: When something breaks, you know exactly where to look
  • Testing is a breeze: Mock out dependencies without complex gymnastics

Finding the Sweet Spot

The magic happens when you hit the sweet spot: high cohesion with low coupling. When I manage to achieve this balance, my systems become:

  1. Truly modular: I can work on one part without touching (or even understanding) all the others
  2. Actually flexible: When requirements change (and they always do!), I can adapt without a complete rewrite
  3. Surprisingly robust: A bug in one component doesn’t bring down the entire application
  4. Easy to understand: New team members can get productive quickly because they only need to understand the parts they’re working on, not the entire codebase

How I Improve Cohesion and Coupling in My Code

Let me share some hands-on techniques I’ve learned to fix these issues when I spot them in my code:

Making Classes More Cohesive

  • Follow the “one reason to change” rule, if a feature request would touch multiple classes instead of just one, that’s often a good sign!
  • When I notice a class doing too many things, I break it into specialized classes
  • Big methods are usually doing too much, I split them into smaller, focused methods
  • I keep related data and the code that uses it together
  • If a method doesn’t use the class’s data, it probably belongs somewhere else

Loosening Tight Coupling

  • I pass dependencies in through constructors instead of creating them inside methods
  • I write code to interfaces (what something does) rather than implementations (how it does it)
  • I make sure high-level components don’t depend on low-level details
  • I use abstraction layers as “firewalls” to contain changes
  • I avoid global variables and singletons that create hidden dependencies, they’re like secret relationships that nobody knows about until something breaks!

Conclusion

These twin principles of cohesion and coupling aren’t just academic concepts for software architecture books. They’re practical tools I use every day to keep my code from turning into a nightmare.

I’ve learned this lesson the hard way, after spending days hunting down bugs in spaghetti code where everything was connected to everything else. Now when I design systems, I keep asking myself two simple questions:

  • “Does this class do exactly one thing?”
  • “Could I replace this component without breaking everything else?”

The more I can answer “yes” to both, the happier my future self (and my teammates) will be. Because let’s be honest—we spend way more time reading and maintaining code than writing it from scratch.