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:
| Step | Concern | Call | Result |
|---|---|---|---|
| 1 | Service | isValidUsername("ada") | len 3 → true |
| 2 | Service | isValidEmail("ada@calc.io") | regex matches → true |
| 3 | Service → Repo | existsByEmail("ada@calc.io") | SELECT returns no row → false |
| 4 | Service → Repo | addUser("ada", "ada@calc.io") | INSERT, generated key → 1 |
| 5 | Main | println | created 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.
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
- Leaky abstractions. If
UserServicecatchesSQLExceptionor builds aWHEREclause, the persistence concern has bled upward. The repository should expose intent (existsByEmail), not SQL. - Anaemic vs God layers. Over-splitting produces dozens of one-method classes that do nothing but forward calls (indirection with no payoff); under-splitting produces a 2,000-line
UserManagerthat does HTTP parsing, validation, and SQL. Both defeat the point. - Duplicated rules across layers. The uniqueness check exists both as a service rule and a DB
UNIQUEconstraint. That is fine (defence in depth) if you decide it deliberately; it becomes a bug when two layers disagree on the rule and you cannot tell which is authoritative. - Hidden cross-layer coupling via shared types. Passing a raw JDBC
ResultSetup to the service ties the service’s lifetime to an open connection — close it and the service breaks. Map to a plainUserobject at the boundary. - Concern chosen along the wrong axis. Splitting by technical layer when the real change-axis is feature (billing vs auth) means one feature change still edits every layer. Pick the axis that actually varies.
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.
- Reach for layered SoC when the concerns change at different rates or for different reasons (DB vendor, validation policy, transport), more than one person/team touches the code, or you need to unit-test rules without a database. The signal: “I want to change X without re-reading or re-testing Y.”
- Keep it in one place when the program is a 50-line script, a throwaway spike, or a piece where the “layers” would always change together anyway. Premature separation here costs you indirection, more files, and constructor wiring for zero isolation benefit.
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
BillingRepositoryThe 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
- SoC = one module per change-reason, talking only through narrow interfaces, so a change touches one box (see the diagram: a rule change never reaches the DB layer).
- It is SRP scaled up from the class to the layer/module partition.
- Demonstrate separation with real method bodies and real call edges — placeholder
return truestubs prove nothing. - Split along the axis that actually varies, and stop splitting before indirection outweighs isolation.
Follow-up drills (staff probes)
- 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.
- Trace a leaky ResultSet upward.
Suppose
UserRepository.findreturns a live JDBCResultSetandUserServiceiterates it after the connection closes. The service now depends on connection lifetime (a persistence concern). Fix: map to a plainUserDTO/entity inside the repository, close the ResultSet there, and return a domain object. The service must never importjava.sql.*. - 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.
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.
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.
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.
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.