Hussaini Marsidi
Main page
12 December 20246 minutes read

At some point, every developer hears about Separation of Concerns (SoC). It's usually introduced as a structural idea: “put your code in different files,” or “keep your logic separate from your views.” It sounds neat. Clean. Reasonable.

And it is. But that’s not really it.

You can have a beautiful folder tree, tidy filenames, and all the services/, controllers/, and repositories/ in the world and still end up with a tangled mess of code that’s brittle, confusing, and impossible to test. Because SoC isn’t about where your code lives. It’s about what your code is responsible for and more importantly, what it isn’t responsible for.

So what is SoC, really?

Let’s explore that. And while we’re at it, let’s talk about cohesion and coupling — the two forces that quietly shape how your code behaves in the wild.


Two Hidden Forces

Cohesion is about focus. If your function, class, or module does one thing well, it’s cohesive. If it jumps between five unrelated jobs, it’s not.

Coupling is about dependency. If one piece of code needs to know too much about another to work, they’re tightly coupled. If they talk through clear boundaries and stay out of each other’s business, they’re loosely coupled.

High cohesion. Low coupling. That’s the sweet spot.


So What Does High Cohesion Look Like?

Here’s what not to do:

class GeneralService {
  createUser(data) {
    // Save user to database
  }

  processPayment(userId, amount) {
    // Charge user via payment gateway
  }

  sendEmail(userId, subject, body) {
    // Send transactional email
  }

  generateReport() {
    // Aggregate data and format a report
  }
}

This class is overworked. It knows too much, does too much, and gives away its secrets. Change how payments work? You might accidentally break user creation.

Let’s split it:

class UserService {
  createUser(data) {
    // Save user to database
  }

  getUser(id) {
    // Fetch user from database
  }

  updateUser(id, data) {
    // Update user details
  }
}

class PaymentService {
  processPayment(userId, amount) {
    // Integrate with payment provider
  }

  refundPayment(paymentId) {
    // Handle refunds
  }
}

class EmailService {
  sendWelcomeEmail(user) {
    // Build welcome template and send email
  }

  sendNotificationEmail(user, subject, content) {
    // Send generic notifications
  }
}

Now we’re talking. Each service has one job. It’s focused. Cohesive. We can change the payment logic without worrying we’ll break the welcome email.


What About Coupling?

Imagine you have two components: one that handles user management and one that deals with payments. If they’re tightly coupled (i.e., the user management component is directly dependent on the payment component), changing one will likely break the other. That’s high coupling.

Instead, if they interact via well-defined interfaces or through shared services, they’re loosely coupled. This means that if the payment logic changes, the user management component doesn’t break. This is the goal.

Let’s say you’re building a system where users can sign up and immediately be charged for a subscription.

❌ High coupling

class UserService {
  constructor() {
    this.paymentService = new PaymentService();
  }

  createUser(data) {
    const user = db.users.insert(data);
    this.paymentService.charge(user.id, data.plan);
    return user;
  }
}

Here, UserService knows too much about how payments work. It creates a PaymentService instance itself and calls it directly. If PaymentService changes, UserService probably breaks.

You’re stuck with both moving together.

✅ Low coupling

class UserService {
  constructor(paymentProcessor) {
    this.paymentProcessor = paymentProcessor;
  }

  createUser(data) {
    const user = db.users.insert(data);
    this.paymentProcessor.charge(user.id, data.plan);
    return user;
  }
}

// wiring it up
const paymentService = new PaymentService();
const userService = new UserService(paymentService);

Now UserService relies on a contract, not a specific implementation. You could replace PaymentService with a mock in tests, or switch providers later without changing UserService.

In short

  • Tight coupling = can’t change one without changing the other
  • Loose coupling = change flows in one direction without breaking everything else

Layers, Not Just Files

When you think in layers instead of folders, your system gets clearer:

  • Controller Layer — Receives the request.
  • Service Layer — Decides what to do.
  • Repository Layer — Talks to the database.

It’s not about where the files live. It’s about who owns what concern


Example: Layered Architecture in Action

Let’s walk through a simple example where we build a small app that manages users.

Controller Layer (Handles Requests)

class UserController {
  constructor(userService) {
    this.userService = userService;
  }

  async createUser(req, res) {
    try {
      const user = await this.userService.createUser(req.body);
      return res.status(201).json(user);
    } catch (error) {
      return res.status(400).json({ error: error.message });
    }
  }
}

Service Layer (Business Logic)

class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async createUser(data) {
    if (!data.email) throw new Error("Email is required");
    return this.userRepository.saveUser(data);
  }
}

Repository Layer (Data Access)

class UserRepository {
  async saveUser(data) {
    return db.users.insert(data);
  }
}

Presentation Layer (React Components)

UserItem Component

const UserItem = ({ user }) => {
  return (
    <div className="p-4 border rounded-lg shadow">
      <h2 className="text-lg font-semibold">{user.name}</h2>
      <p className="text-gray-600">{user.email}</p>
    </div>
  );
};

Userlist component

import { useEffect, useState } from "react";
import { UserRepository } from "../repositories/UserRepository";
import { UserService } from "../services/UserService";
import { UserController } from "../controllers/UserController";
import UserItem from "./UserItem";

const UserList = () => {
  const [users, setUsers] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    const userRepository = new UserRepository();
    const userService = new UserService(userRepository);
    const userController = new UserController(userService);
    userController.getUsers(setUsers, setError);
  }, []);

  if (error) return <p className="text-red-500">Error: {error}</p>;

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">User List</h1>
      <div className="grid gap-4">
        {users.map(user => (
          <UserItem key={user.id} user={user} />
        ))}
      </div>
    </div>
  );
};

export default UserList;

Final Thoughts

Separation of Concerns is all about keeping things simple, organized, and easy to maintain. By splitting responsibilities into layers and focusing on high cohesion and low coupling, you make your code more flexible, scalable, and testable. You also make your job as a developer much easier when it comes to extending the system in the future. In the end, SoC is just one of those practices that helps you write cleaner, more maintainable software.

© 2026 Hussaini Marsidi. Made in Malaysia 🇲🇾.