Design Principle
Separation of Concerns
Partition systems by the decisions they protect to keep change local and safe. Learn to separate concerns effectively for maintainable, scalable architecture.
Separation of Concerns
Why This Matters
Think of separation of concerns like organizing a restaurant. The kitchen handles cooking, the waitstaff handles serving, and the cashier handles payments. Each has a clear responsibility. If the cashier had to cook, or the cook had to handle payments, things would get messy. Separation of concerns does the same for code—each part has a clear responsibility, and changes to one part don't affect others.
This matters because code changes constantly. New features, bug fixes, refactoring—every change risks breaking something else. Separation of concerns helps by isolating changes. When you need to change how data is stored, you shouldn't have to touch the UI. When you need to change the UI, you shouldn't have to touch the business logic.
In interviews, when someone asks "How would you refactor this code?", they're testing whether you understand separation of concerns. Can you identify when code is mixing concerns? Can you separate them? Most engineers can't. They write code that mixes UI, business logic, and data access, making it hard to change.
What Engineers Usually Get Wrong
Engineers often think "separation of concerns means separate files." But separation of concerns is about isolating decisions, not just organizing code. You can have separate files but still mix concerns if the files depend on each other in the wrong ways. The key is: each concern should be able to change independently.
Engineers also confuse separation of concerns with layers. You can have layers (UI, service, data) but still mix concerns within a layer. Separation of concerns is about isolating what changes for different reasons, not just organizing by technical layers.
How This Breaks Systems in the Real World
A service had a UserService class that handled user creation, user updates, email sending, password hashing, and database operations. This worked fine initially. But when the team needed to change how emails were sent (switch from SMTP to an email service API), they had to modify UserService. When they needed to change password hashing (switch algorithms), they had to modify UserService again. Each change risked breaking other functionality. The class became a bottleneck—everyone was afraid to touch it.
The fix? Separate concerns. Split into UserRepository (database operations), EmailService (email sending), PasswordHasher (password operations), and UserService (orchestration). Now, changes to email don't affect password hashing, and vice versa.
Another story: A service mixed UI and business logic. The UI code directly called database queries and performed business calculations. When the team needed to change the UI (switch from web to mobile), they had to rewrite all the business logic. The fix? Separate UI from business logic. Create a service layer that handles business logic, and the UI just calls the service. Now you can change the UI without touching business logic.
1. Clarify the boundaries
Identify the actors in your flow—UI, domain, persistence—and decide what each is allowed to know.
1// UI layer: declares user intent.2export function TransferForm() {3 const { mutate, status } = useTransferFunds();4 const onSubmit = (values: TransferRequest) => mutate(values);5 return <TransferView status={status} onSubmit={onSubmit} />;6}78// Domain service: enforces business rules.9class TransferService {10 constructor(private readonly accounts
Result: UI tests mock the service; domain tests fake the gateway. Each change stays local.
2. Contract with events, not database calls
Define contracts at the seams to avoid leaking implementation details.
1// Domain publishes intent.2class BillingService {3 constructor(private readonly events: EventBus) {}45 async charge(invoice: Invoice) {6 // ...7 await this.events.publish("invoice.charged", { invoiceId: invoice.id });8 }9}1011// Notifications concern subscribes to the event.12events.subscribe("invoice.charged", async ({ invoiceId }) => {13 const invoice = await invoices.invoiceId
Result: Notifications can evolve independently; the billing core stays tidy.
3. Maintain the seam
Each concern should expose just enough surface for collaborators.
1interface ReportingStore {2 saveStatement(statement: MonthlyStatement): Promise<void>;3}45interface StatementGenerator {6 generate(month: string): Promise<MonthlyStatement>;7}89class StatementsController {10 constructor(11 private readonly generator: StatementGenerator,12 private readonly store: ReportingStore,13 ) {}1415 async run(month: string) {16 statement generatormonth
Result: Changing the storage backend or the statement generator happens in isolation.
Debug checklist
- Does a test need to reach across multiple layers? Consider moving the responsibility.
- Are you revalidating domain rules in your controller? Deduplicate in the service layer.
- Can you swap an implementation without touching the consumer? If not, refine the seam.
Takeaway: Separation of concerns is a guardrail for readable architecture. It keeps the social contract between layers explicit, enabling calm refactors.
Interview Questions
Beginner
Q: What is separation of concerns? Give an example of code that violates it.
A: Separation of concerns means partitioning a system so that each part handles a single responsibility or "concern." Each module should protect one type of decision and be isolated from changes in other concerns.
Violation example: A UI component directly calling database queries:
1function TransferForm() {2 const handleSubmit = async (values) => {3 // UI directly accessing database - mixing presentation and persistence4 await db.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2",5 [values.amount, values.sourceId]);6 };7}
Fix: Separate into UI (declares intent), domain service (enforces business rules), and data gateway (handles persistence). Each layer only knows about its immediate neighbors through well-defined interfaces.
Intermediate
Q: How would you refactor a monolithic service that handles user authentication, sends emails, and processes payments? Apply separation of concerns.
A:
Identify concerns:
- Authentication (who is the user?)
- Email delivery (notifications)
- Payment processing (financial transactions)
Separate into services with clear boundaries:
1// Authentication Service - only handles identity2class AuthService {3 async login(credentials) { /* ... */ }4 async validateToken(token) { /* ... */ }5}67// Email Service - only handles notifications8class EmailService {9 async sendWelcome(email) { /* ... */ }10 async sendReceipt(email, invoice) { /* ... */ }11}1213// Payment Service - only handles transactions14class PaymentService {15 async processPayment(amount, method) {
Use events for cross-cutting concerns:
- Payment service publishes "payment.completed" event
- Email service subscribes and sends receipt
- No direct dependency between payment and email
Result: Changing email provider doesn't affect payment logic. Each service can evolve independently.
Senior
Q: Design a microservices architecture for a ride-sharing platform (Uber/Lyft). How do you apply separation of concerns at the service level? How do services communicate while maintaining boundaries?
A:
Core services (each owns one concern):
- User Service - user identity, profiles, authentication
- Ride Service - ride requests, matching, ride lifecycle
- Payment Service - payment processing, invoicing
- Notification Service - SMS, push, email
- Location Service - real-time tracking, geocoding
- Pricing Service - fare calculation, surge pricing
- Driver Service - driver management, availability
Separation principles:
- Data ownership: Each service owns its database. Ride service doesn't query user table directly.
- API contracts: Services expose focused APIs. User service has
getUser(id), notgetUserWithPaymentHistory(id). - Event-driven communication: Services publish events, subscribe to what they need:
- Ride service publishes "ride.completed"
- Payment service subscribes, processes charge
- Notification service subscribes, sends receipt
- No direct service-to-service calls for cross-cutting concerns
Boundary maintenance:
- API Gateway: Single entry point, routes to appropriate service
- Service mesh: Handles service discovery, load balancing, retries
- Event bus: Decouples services (Kafka, RabbitMQ)
- Saga pattern: For distributed transactions (e.g., ride completion triggers payment, then notification)
Failure handling:
- If payment service is down, ride completes but payment is queued
- If notification fails, ride still completes (eventually consistent)
- Each service can fail independently without cascading failures
Key insight: Separation of concerns at microservice level means each service can be developed, deployed, and scaled independently. The "social contract" between services is explicit through APIs and events.
Failure Stories You'll Recognize
The Mixed Concerns: A service had a UserService class that handled user creation, user updates, email sending, password hashing, and database operations. This worked fine initially. But when the team needed to change how emails were sent (switch from SMTP to an email service API), they had to modify UserService. When they needed to change password hashing (switch algorithms), they had to modify UserService again. Each change risked breaking other functionality. The class became a bottleneck—everyone was afraid to touch it. The fix? Separate concerns. Split into UserRepository (database operations), EmailService (email sending), PasswordHasher (password operations), and UserService (orchestration). Now, changes to email don't affect password hashing, and vice versa.
The UI-Business Logic Mix: A service mixed UI and business logic. The UI code directly called database queries and performed business calculations. When the team needed to change the UI (switch from web to mobile), they had to rewrite all the business logic. The fix? Separate UI from business logic. Create a service layer that handles business logic, and the UI just calls the service. Now you can change the UI without touching business logic.
The Leaky Abstraction: A team created a data access layer that was supposed to hide database details. But the abstraction leaked database-specific concepts (transactions, connection pooling) into the interface. When they needed database-specific features, they had to work around the abstraction. The abstraction made the code harder to understand and use. The fix? Make boundaries explicit. If you need database-specific features, expose them clearly rather than hiding them behind a leaky abstraction.
What Interviewers Are Really Testing
They want to hear you talk about separation of concerns as isolating decisions, not just organizing code. Junior engineers say "separate UI, service, and data layers." Senior engineers say "separate concerns by what changes for different reasons. Each concern should be able to change independently. Use events/APIs as boundaries, not database calls. Keep seams explicit."
When they ask "How would you refactor this code?", they're testing:
-
Can you identify when code is mixing concerns?
-
Do you know how to separate concerns?
-
Can you design systems where changes are isolated?
-
Partition by decisions, not just layers - each concern protects one type of decision (UI, domain, persistence)
-
Clarify boundaries - identify actors (UI, domain, persistence) and decide what each is allowed to know
-
Contract with events, not database calls - use events/APIs as concern boundaries, avoid leaking implementation details
-
Maintain the seam - each concern should expose just enough surface for collaborators
-
UI tests mock the service; domain tests fake the gateway - separation enables isolated testing
-
Changing storage backend or business logic happens in isolation - well-defined seams prevent cascading changes
-
Debug checklist: Does a test need multiple layers? Are you revalidating rules? Can you swap implementations?
-
Separation of concerns keeps the social contract between layers explicit - enabling calm refactors
How InterviewCrafted Will Teach This
We'll teach this through real code problems, not abstract principles. Instead of memorizing "separate concerns," you'll learn through scenarios like "why is this code hard to change when we modify one part?"
You'll see how separation of concerns helps you write maintainable, testable code. When an interviewer asks "how would you refactor this code?", you'll think about isolating decisions, defining boundaries, and keeping changes local—not just "separate into layers."
- SOLID Principles - Separation of concerns is fundamental to SOLID principles, especially Single Responsibility Principle. Understanding SOLID helps understand separation of concerns.
- DRY, KISS & YAGNI - Separation of concerns helps keep code DRY and KISS. Understanding these principles helps understand separation of concerns benefits.
- Composition Over Inheritance - Composition helps separate concerns better than inheritance. Understanding composition helps understand separation of concerns.
- Factory Pattern - Factories separate creation logic from usage. Understanding factories helps understand separation of concerns.
- Observer Pattern - Observer pattern helps separate publisher and subscriber concerns. Understanding Observer helps understand separation of concerns.
Key Takeaways
Partition by decisions, not just layers - each concern protects one type of decision (UI, domain, persistence)
Clarify boundaries - identify actors (UI, domain, persistence) and decide what each is allowed to know
Contract with events, not database calls - use events/APIs as concern boundaries, avoid leaking implementation details
Maintain the seam - each concern should expose just enough surface for collaborators
UI tests mock the service; domain tests fake the gateway - separation enables isolated testing
Changing storage backend or business logic happens in isolation - well-defined seams prevent cascading changes
Debug checklist: Does a test need multiple layers? Are you revalidating rules? Can you swap implementations?
Separation of concerns keeps the social contract between layers explicit - enabling calm refactors
Related Topics
SOLID Principles
Separation of concerns is fundamental to SOLID principles, especially Single Responsibility Principle. Understanding SOLID helps understand separation of concerns.
DRY, KISS & YAGNI
Separation of concerns helps keep code DRY and KISS. Understanding these principles helps understand separation of concerns benefits.
Composition Over Inheritance
Composition helps separate concerns better than inheritance. Understanding composition helps understand separation of concerns.
Factory Pattern
Factories separate creation logic from usage. Understanding factories helps understand separation of concerns.
Observer Pattern
Observer pattern helps separate publisher and subscriber concerns. Understanding Observer helps understand separation of concerns.
Keep exploring
Principles work best in chorus. Pair this lesson with another concept and observe how your architecture conversations change.