← Home
4 min read

Separation of Concerns

in-depth · best-practices

Separation of Concerns

At some point, every developer hears about Separation of Concerns. It usually gets introduced as a structural idea: “put your code in different files,” “keep your logic away from your views,” that kind of thing. And people run with it. They create services/, controllers/, repositories/, give everything a tidy name, and call it done.

Then six months later the codebase is still a mess and nobody knows why.

SoC isn’t about where your code lives. It’s about what your code is responsible for, and more importantly, what it isn’t.

You can have the most beautiful folder structure in the world and still have a UserService that handles payments, sends emails, and generates reports. The files are separated. The concerns are not.


Two forces you need to understand first

Before we go further, two concepts worth having in your head. Not as abstract theory: as tools you actively use when reading and writing code.

Cohesion: how focused a unit of code is on a single job.

  • High cohesion: this function/class does one thing and does it well
  • Low cohesion: this function/class jumps between unrelated responsibilities
  • The tell: if you struggle to name a class in one word, it’s probably doing too much

Coupling: how much one piece of code depends on the internals of another.

  • Loose coupling: two pieces talk through a clear boundary, neither needs to know how the other works
  • Tight coupling: change one, probably break the other
  • The tell: if you can’t test a class without setting up three others, they’re too tangled

The goal is high cohesion, low coupling. That’s it. SoC is just what it looks like when you actually get there.


What low cohesion looks like in practice

class GeneralService {
  createUser(data) {}
  processPayment(userId, amount) {}
  sendEmail(userId, subject, body) {}
  generateReport() {}
}

This class is doing four completely unrelated jobs. Change how payments work and you are in a class that also handles user creation. You have no idea what you might break. And good luck writing a test that doesn’t pull in the whole world.

Split it by responsibility:

class UserService {
  createUser(data) {}
  getUser(id) {}
  updateUser(id, data) {}
}

class PaymentService {
  processPayment(userId, amount) {}
  refundPayment(paymentId) {}
}

class EmailService {
  sendWelcomeEmail(user) {}
  sendNotificationEmail(user, subject, content) {}
}

Now each class has one job. You can change payment logic without touching email. You can test UserService without a payment provider in the room.


What tight coupling looks like in practice

Here is a pattern I see a lot. Looks harmless at first:

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

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

UserService is creating its own PaymentService internally. It owns the dependency. Which means it owns the risk. If PaymentService changes its constructor, its method names, anything, UserService breaks. And you cannot test UserService without a real payment service running.

The fix is simpler than people think:

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

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

const userService = new UserService(new PaymentService());

UserService now depends on a contract, not an implementation. Pass in a mock for testing. Swap providers later without touching this class. The two can evolve independently.


Layers are about ownership, not folders

When people think about SoC at the architecture level, they usually land on layers. That’s fine, but the point isn’t the folder names. It’s who owns what decision.

  • Controller: owns the request. Knows HTTP, knows nothing else.
  • Service: owns the business logic. Knows the rules, not the database.
  • Repository: owns the data. Knows the database, nothing else.

Here is what that looks like wired together:

// Controller: handles the request, nothing more
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: owns the rules
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: owns the database
class UserRepository {
  async saveUser(data) {
    return db.users.insert(data);
  }
}

Each layer knows exactly one thing. The controller has no idea how the database works. The repository has no idea what the business rules are. If you need to swap your ORM, you touch one file. If the validation rules change, the repository never hears about it.


The two failure modes

People tend to get SoC wrong in one of two directions.

Too loose: everything in one place. The UserService that does payments and emails. The React component that fetches data, transforms it, and renders it. It works until it doesn’t, and when it breaks, good luck tracing it.

Too extreme: over-splitting for its own sake. A separate file for every tiny function. Abstractions on top of abstractions where a single line of code goes through four layers to do something simple. This is SoC as performance, not as a tool.

The right question is always: does this boundary make the code easier to understand, change, and test? If yes, draw it. If you’re drawing it to look organised, don’t.

SoC is not a rule to follow. It’s a judgment call you make every time you sit down to write code. The goal is a system where each part has a clear job, and you can change one part without dreading what else might break.

That’s it. Everything else is just mechanics.