Design Principle
Separation of Concerns
Partition systems by the decisions they protect to keep change local and safe.
Separating concerns is less about layered diagrams and more about isolating decisions. Each slice of the system should defend a single responsibility.
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() {3const { mutate, status } = useTransferFunds();4const onSubmit = (values: TransferRequest) => mutate(values);5return <TransferView status={status} onSubmit={onSubmit} />;6}78// Domain service: enforces business rules.9class TransferService {10constructor(private readonly accounts: AccountsGateway) {}1112async transfer(request: TransferRequest) {13 const { source, destination, amount } = request;14 await this.ensureFunds(source, amount);15 await this.accounts.move({ source, destination, amount });16}17}1819// Data gateway: talks to the database.20class AccountsGateway {21async move(payload: MovePayload) {22 return db.tx(/* ... */);23}24}
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 {3constructor(private readonly events: EventBus) {}45async 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 }) => {13const invoice = await invoices.find(invoiceId);14await email.sendChargeReceipt(invoice);15});
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 {2saveStatement(statement: MonthlyStatement): Promise<void>;3}45interface StatementGenerator {6generate(month: string): Promise<MonthlyStatement>;7}89class StatementsController {10constructor(11 private readonly generator: StatementGenerator,12 private readonly store: ReportingStore,13) {}1415async run(month: string) {16 const statement = await this.generator.generate(month);17 await this.store.saveStatement(statement);18}19}
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:
function TransferForm() {
const handleSubmit = async (values) => {
// UI directly accessing database - mixing presentation and persistence
await db.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[values.amount, values.sourceId]);
};
}
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:
// Authentication Service - only handles identity
class AuthService {
async login(credentials) { /* ... */ }
async validateToken(token) { /* ... */ }
}
// Email Service - only handles notifications
class EmailService {
async sendWelcome(email) { /* ... */ }
async sendReceipt(email, invoice) { /* ... */ }
}
// Payment Service - only handles transactions
class PaymentService {
async processPayment(amount, method) { /* ... */ }
}
// Orchestration layer - coordinates but doesn't implement
class UserOnboardingService {
constructor(
private auth: AuthService,
private email: EmailService,
private payment: PaymentService
) {}
async onboardUser(userData) {
const user = await this.auth.register(userData);
await this.email.sendWelcome(user.email);
// Payment happens separately when user subscribes
}
}
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.
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
Keep exploring
Principles work best in chorus. Pair this lesson with another concept and observe how your architecture conversations change.