Separation of concerns: one job per module
Apply separation of concerns to keep each class, module, and layer focused on a single aspect of functionality, making code easier to change and test.
You open a class called OrderService. It validates input, calculates discounts, persists to a database, sends a confirmation email, and publishes an analytics event. Five responsibilities, one class, and every change to any of those five things forces you to re-read, retest, and redeploy the whole blob. That is the cost of tangled concerns.
Separation of Concerns (SoC) is the principle that says: split your code so that each unit handles exactly one aspect of functionality. Not one feature. One aspect. Validation is an aspect. Persistence is an aspect. Notification is an aspect. When they live in separate places, you can change one without touching the others.
I reach for SoC as a default lens in every code review. If a class does two things, it is a candidate for splitting. If it does five things, it is overdue.
What Is Separation of Concerns
SoC was coined by Edsger Dijkstra in 1974. The core idea: a "concern" is any distinct piece of program functionality, and each concern should live in its own module. The word "module" here is deliberately vague. It can mean a class, a package, a microservice, or even a function. The scale changes; the principle does not.
Think of it like a restaurant kitchen. The grill station does not also wash dishes. The pastry section does not plate entrees. Each station handles one concern, and the head chef coordinates between them. If the grill breaks, you fix the grill. You do not rewire the dishwasher.
For your interview: SoC is the parent principle behind Single Responsibility, MVC, layered architecture, and even microservices. Every architectural pattern you name is, at its root, an application of SoC at a different scale.
Each box on the right owns one aspect. Changes to email templates do not require retesting pricing logic. Changes to the analytics schema do not risk breaking order persistence.
Horizontal vs Vertical Separation
SoC manifests in two shapes, and most systems use both simultaneously.
Horizontal separation slices by technical layer. Each layer handles one kind of work across all features: controllers handle HTTP, services handle business logic, repositories handle data access. This is the classic three-tier architecture you see in almost every web application.
Vertical separation slices by business feature. Each slice owns everything about one domain entity: the user module has its own controller, service, and repository. The order module has its own. They share infrastructure but nothing else.
| Dimension | Horizontal (layers) | Vertical (features) |
|---|---|---|
| Split axis | Technical responsibility | Business domain |
| Example | Controller / Service / Repository | UserModule / OrderModule / PaymentModule |
| Strongest benefit | Consistent patterns across features | Changes stay within one module |
| Weakness | Cross-layer changes for one feature touch 3+ files | Shared infrastructure can be duplicated |
| Best for | Small-to-medium codebases, teams organized by skill | Large codebases, teams organized by product area |
Most real projects start horizontal and migrate toward vertical as the team grows. In an interview, mention both and say which one fits the system you are designing. That shows maturity.
The Problem: A Monolithic Class
Here is a class that handles everything related to placing an order. It validates, calculates totals, saves to the database, sends an email, and publishes an analytics event. Five concerns, one class.
// β Five concerns in a single class
public class OrderService {
private final DataSource dataSource;
private final EmailClient emailClient;
private final AnalyticsClient analyticsClient;
public OrderService(DataSource ds, EmailClient ec, AnalyticsClient ac) {
this.dataSource = ds;
this.emailClient = ec;
this.analyticsClient = ac;
}
public Order placeOrder(OrderRequest request) {
// Concern 1: Validation
if (request.items().isEmpty()) throw new IllegalArgumentException("No items");
for (var item : request.items()) {
if (item.quantity() <= 0) throw new IllegalArgumentException("Bad quantity");
if (item.unitPrice() <= 0) throw new IllegalArgumentException("Bad price");
}
// Concern 2: Pricing
double total = 0;
for (var item : request.items()) {
total += item.unitPrice() * item.quantity();
}
if (request.couponCode() != null) {
total *= 0.9; // hardcoded 10% discount
}
// Concern 3: Persistence
try (var conn = dataSource.getConnection()) {
var stmt = conn.prepareStatement(
"INSERT INTO orders (customer_id, total, status) VALUES (?, ?, ?)"
);
stmt.setString(1, request.customerId());
stmt.setDouble(2, total);
stmt.setString(3, "PLACED");
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Failed to save order", e);
}
// Concern 4: Notification
emailClient.send(
request.customerEmail(),
"Order placed",
"Your order total: $" + total
);
// Concern 5: Analytics
analyticsClient.track("order_placed", Map.of(
"customer_id", request.customerId(),
"total", String.valueOf(total)
));
return new Order(request.customerId(), total, "PLACED");
}
}
Every unit test for this method needs a DataSource, an EmailClient, and an AnalyticsClient, even if you only want to test pricing logic. Every change to email formatting risks breaking the SQL statement above it. I have seen production incidents caused by exactly this kind of entanglement: a developer changes the analytics payload and accidentally introduces a null that breaks the persistence block earlier in the method.
Implementation: Clean Separation
Split each concern into its own class. The orchestrating service calls each one in sequence but owns none of their internal logic.
/**
* Concern: input validation.
* Knows nothing about databases, emails, or pricing.
*/
public class OrderValidator {
public void validate(OrderRequest request) {
if (request.items().isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
for (var item : request.items()) {
if (item.quantity() <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive for product: " + item.productId()
);
}
if (item.unitPrice() <= 0) {
throw new IllegalArgumentException(
"Unit price must be positive for product: " + item.productId()
);
}
}
}
}The orchestrator's placeOrder method is six lines. Each line calls one concern. You can unit-test PricingCalculator with zero mocks (it is pure computation). You can test OrderValidator without a database. You can swap NotificationService from email to SMS by changing one class. That is the payoff.
SoC at Different Levels
Separation of concerns is not just a class-level principle. It applies at every layer of a system, and recognizing this is what distinguishes a junior answer from a senior one in interviews.
| Level | Concern boundary | Example |
|---|---|---|
| Method | A single step in a process | validate() does only validation |
| Class | A single aspect of functionality | OrderValidator knows nothing about persistence |
| Package | A single domain or feature | com.shop.payments contains all payment-related classes |
| Module (JAR/library) | A single bounded context | payment-core.jar has no dependency on email infrastructure |
| System (service) | A single business capability | The Payment Service does not also handle inventory |
The principle is identical at every scale. Only the blast radius of a violation changes. A tangled method wastes minutes. A tangled service wastes months.
My recommendation: in an LLD interview, show class-level SoC with your code structure. In an HLD interview, show system-level SoC with your architecture diagram. Same principle, different zoom level.
Common Mistakes
1. Mixing concerns because "it is just one more thing"
The most common SoC violation happens gradually. Nobody writes a 500-line method on purpose. They add one concern, then another, then a third. Each addition is "just a few lines." Six months later, the method does five things and nobody wants to refactor it because everything depends on it.
The fix: when you add a new behavior to an existing method, ask whether it is the same concern or a new one. If new, extract it before committing.
2. Over-separating trivial code
SoC is not an excuse to create 15 classes for a 20-line feature. I have reviewed code where someone split a simple CRUD endpoint into CreateOrderCommand, CreateOrderCommandHandler, CreateOrderCommandValidator, CreateOrderCommandResult, and CreateOrderCommandResultMapper. Five classes, 200 lines of boilerplate, zero business complexity.
If the entire concern fits in a single method with no branching, it does not need its own class. Separation is a tool, not a religion.
The over-abstraction trap
If your separated classes are all under 10 lines and you have more files than logic, you have gone too far. Group trivial concerns together until each unit has enough substance to justify its own file.
3. Splitting by technical layer when you should split by feature
A common pattern in Spring Boot projects: one validators package with 30 validators for 15 different features, one services package with 30 services, one repositories package with 30 repositories. Finding all the code for one feature means opening three packages and scanning 30 files in each.
Vertical separation (by feature) is often better for large codebases. All order-related code lives in com.shop.orders. All user-related code lives in com.shop.users. When you work on orders, you open one package.
4. Leaking concerns through return types
You can separate classes perfectly and still leak concerns through data. If your OrderRepository returns a ResultSet instead of a domain Order, the calling code now knows about JDBC. The concern of "how data is stored" leaks into the service layer.
Return domain objects from each concern boundary. Let each layer translate between its internal representation and the domain model.
Interview tip: name the layers
When you sketch an architecture in an interview, label each box with its concern. "This layer handles HTTP routing, this layer handles business rules, this one handles persistence." Naming the concerns explicitly signals that you think in terms of SoC, and interviewers notice.
Test Your Understanding
Quick Recap
- Separation of Concerns says each module should handle exactly one aspect of functionality, so changes to one concern do not cascade into unrelated code.
- Horizontal separation splits by technical layer (controller, service, repository); vertical separation splits by business feature (user module, order module).
- The biggest payoff is independent testability: pure-logic concerns like pricing can be tested with zero mocks or infrastructure.
- SoC applies at every scale: method, class, package, module, and service. The principle is identical; only the blast radius of a violation changes.
- The most common mistake is gradual entanglement, where "just one more concern" turns a clean class into an untestable monolith.
- Over-separation is equally harmful. If your split creates more boilerplate than logic, you have gone too far. Separate only when parts change independently, need independent testing, or may be independently replaced.
- In interviews, name the concern each component owns. Saying "this layer handles X" signals architectural thinking and immediately builds credibility.