CMD Guide
HomeOO & Low-Level DesignSOLID Principles

Separation of Concerns

Separation of Concerns (SoC) works by drawing boundaries so that each distinct kind of decision — how data is stored, what the business rules are, how a request enters the system — lives behind a single module that talks to its neighbours only through a narrow, stable interface; a change to one decision then touches one module instead of rippling through the whole program.

It is the structural idea that the Single Responsibility Principle expresses at the class level. SRP says one class, one reason to change; SoC says the same thing one level up: one layer, one concern. A concern is anything you might reasonably want to change in isolation — the database vendor, a validation rule, the transport (HTTP vs CLI).

A worked example you can actually trace

We build user registration and split it into three concerns: persistence (the UserRepository), business rules (the UserService), and the entry point (a main that plays the role of a controller). Note the filenames now match the language — in Java each public class lives in a file of the same name: UserRepository.java, UserService.java, Main.java. Crucially, the bodies are real, so the separation is demonstrated, not asserted.

1. Persistence concern — UserRepository.java

Knows SQL and JDBC. Knows nothing about what makes a username valid — it just writes rows. existsByEmail lets the service ask a question without the service ever seeing a Connection.

import java.sql.*;

public class UserRepository {
    private final Connection connection;

    public UserRepository(String dbName) throws SQLException {
        this.connection = DriverManager.getConnection("jdbc:sqlite:" + dbName);
        try (Statement st = connection.createStatement()) {
            st.executeUpdate(
                "CREATE TABLE IF NOT EXISTS users (" +
                "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                "username TEXT NOT NULL, " +
                "email TEXT NOT NULL UNIQUE)");
        }
    }

    public boolean existsByEmail(String email) throws SQLException {
        String q = "SELECT 1 FROM users WHERE email = ? LIMIT 1";
        try (PreparedStatement ps = connection.prepareStatement(q)) {
            ps.setString(1, email);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next();
            }
        }
    }

    public long addUser(String username, String email) throws SQLException {
        String q = "INSERT INTO users (username, email) VALUES (?, ?)";
        try (PreparedStatement ps =
                 connection.prepareStatement(q, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, username);
            ps.setString(2, email);
            ps.executeUpdate();
            try (ResultSet keys = ps.getGeneratedKeys()) {
                keys.next();
                return keys.getLong(1);
            }
        }
    }
}

2. Business-rules concern — UserService.java

Owns the policy: what a legal username and email look like, and the rule that an email must be unique. It expresses storage needs through the repository’s methods and never builds a SQL string itself.

import java.util.regex.Pattern;

public class UserService {
    private static final Pattern EMAIL =
        Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");

    private final UserRepository repo;

    public UserService(UserRepository repo) {
        this.repo = repo;
    }

    public long registerUser(String username, String email) throws Exception {
        if (!isValidUsername(username))
            throw new IllegalArgumentException("username must be 3-20 chars");
        if (!isValidEmail(email))
            throw new IllegalArgumentException("email is malformed");
        if (repo.existsByEmail(email))
            throw new IllegalStateException("email already registered");
        return repo.addUser(username, email);
    }

    public boolean isValidUsername(String username) {
        return username != null
            && username.length() >= 3
            && username.length() <= 20;
    }

    public boolean isValidEmail(String email) {
        return email != null && EMAIL.matcher(email).matches();
    }
}

3. Entry-point concern — Main.java

Wires the pieces together (dependency injection by hand) and translates the outside world into calls. It owns no rules and no SQL.

public class Main {
    public static void main(String[] args) throws Exception {
        UserRepository repo = new UserRepository("app.db");
        UserService service = new UserService(repo);

        long id = service.registerUser("ada", "ada@calc.io");
        System.out.println("created user id=" + id);

        // second call with the same email is rejected by the service
        service.registerUser("ada2", "ada@calc.io"); // throws IllegalStateException
    }
}

Trace of registerUser("ada", "ada@calc.io") with a fresh app.db:

StepConcernCallResult
1ServiceisValidUsername("ada")len 3 → true
2ServiceisValidEmail("ada@calc.io")regex matches → true
3Service → RepoexistsByEmail("ada@calc.io")SELECT returns no row → false
4Service → RepoaddUser("ada", "ada@calc.io")INSERT, generated key → 1
5Mainprintlncreated user id=1

Now the second call registerUser("ada2", "ada@calc.io"): steps 1–2 pass, step 3’s existsByEmail now returns true, so the service throws IllegalStateException at the rule layer — the INSERT never runs. The uniqueness policy lives in the service; the UNIQUE constraint in the schema is the persistence layer’s independent safety net.

diagram
diagram

Why the naive version is wrong

The version this page used to show declared registerUser and isValidEmail with empty/placeholder bodies that just return true. That looks like SoC but proves nothing: with no real logic you cannot tell whether validation lives in the service or has leaked into the repository, and the register_user/db_operations.py naming mixed Python conventions into Java. The fix is to give every method a real body and a real call edge — the trace above only works because the service genuinely calls the repository and the repository genuinely runs SQL.

Pitfalls

When to use it — and when not to

SoC is the default for anything that outlives a single sprint, but it is a spectrum, not a switch. The decision is which axis to cut along and how many cuts.

SoC vs SRP (the nearest neighbour): they are the same instinct at different scales. SRP governs a single class (“one reason to change”); SoC governs the partition of the whole program into modules/layers. You gain system-level navigability with SoC, but pay with cross-module interfaces and wiring; SRP alone gives you clean classes that can still be piled into one tangled package.

SoC vs a transaction script / God class: the God class gains raw speed of first-write (everything in one place, no interfaces) and costs you every future change (one edit risks all behaviour, untestable in isolation, merge conflicts). Choose layered SoC when the code will be maintained and tested by more than one change-reason; prefer a single transaction script when the code is small, short-lived, or its concerns provably never vary independently.

Concrete call: a 3-month-lifespan internal CSV importer — one Importer class is the right answer. The same importer becoming a multi-tenant ingestion service with pluggable sources — now split source-reading, validation, and persistence, because each one will start changing on its own clock.

Two axes to cut along: layer vs. vertical feature slice

The example above cut horizontally — one box per technical layer (entry → rules → persistence). That isolates technical change-reasons: swap SQLite for Postgres and only UserRepository moves. But there is a second, orthogonal axis. Cut vertically by feature: an auth slice and a billing slice, each owning its own controller + service + repository.

package by LAYER (horizontal)        package by FEATURE (vertical)
  controller/                          auth/
    AuthController                       AuthController, AuthService, AuthRepository
    BillingController                  billing/
  service/                               BillingController, BillingService, BillingRepository
    AuthService
    BillingService                     a change to billing (add proration)
  repository/                          touches ONLY billing/ -- not auth,
    AuthRepository                     and not three separate layer packages
    BillingRepository

The trade-off is which change-reason you want localized. The horizontal cut makes a vendor change local but smears a feature change across all three layer packages; the vertical cut makes a feature change local (billing lives in one slice) but repeats the layering inside every slice. Real systems usually do both — package by feature at the top, then layer within each slice — so both a “swap the DB” change and an “add proration” change each stay in one place. This is the concrete form of the earlier pitfall: cut along the axis that actually varies. If your features change independently but the code is split only by layer, every feature edit still hits all three layers.

Takeaways

Follow-up drills (staff probes)

  1. When is horizontal controller / service / repo layering fake SoC? When every feature change still edits all three layers in lockstep and the "service" only forwards calls. Layers named after technical roles are not concerns if they do not isolate different reasons to change. Real SoC shows up when you can swap the DB vendor without re-reading validation, or change a business rule without reopening JDBC. If the three packages always co-change for the same actor request, you have folders, not separation.
  2. Trace a leaky ResultSet upward. Suppose UserRepository.find returns a live JDBC ResultSet and UserService iterates it after the connection closes. The service now depends on connection lifetime (a persistence concern). Fix: map to a plain User DTO/entity inside the repository, close the ResultSet there, and return a domain object. The service must never import java.sql.*.
  3. 50-line CSV importer — keep merged or split? Keep merged if it is a short-lived, single-purpose script (source, validation, and write always change together under one owner). Split when it becomes multi-tenant ingestion with pluggable sources, independent validation policy, and a durable store — those concerns start changing on different clocks. Premature SoC on a 50-line tool is indirection tax with no isolation payoff.

SoC vs SRP drill answer key: SRP is one class / one actor reason-to-change; SoC is the program partition into modules/layers. You can have clean SRP classes piled into one tangled package (SRP without SoC), or layered folders full of multi-actor god classes (SoC theater without SRP). Staff answers name both scales.


Sources: Edsger W. Dijkstra, “On the role of scientific thought” (1974), which coined “separation of concerns”; Robert C. Martin, Clean Architecture and Agile Software Development (SRP and layer boundaries); Martin Fowler, Patterns of Enterprise Application Architecture (Service Layer, Repository, Transaction Script). Re-authored and deepened for this guide — the prior version mismatched Python filenames against Java code and used placeholder method bodies, so the separation was asserted rather than demonstrated; the example, trace, diagram, pitfalls, trade-off analysis, and interview drills were added.

🤖 Don't fully get this? Learn it with Claude

Stuck on Separation of Concerns? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Separation of Concerns** (OO & Low-Level Design) and want to truly understand it. Explain Separation of Concerns from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Separation of Concerns** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Separation of Concerns** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Separation of Concerns** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes