Separation of Concerns
Stage 3 · Architecture & System Design · B.U.I.L.D. letter: U
You built something that works. Seriously — that's not small. Vibe coding got you a real, running app. Now the question shifts from "does it work?" to "can anyone — including future you — understand it, change it, and trust it?" Separation of concerns is the first and most important answer.
⚠️ The vibe trap
It starts fast and it feels great: one file, one route handler, one function that does everything. But after a few weeks you have an 800-line routes/user.js that fetches from the database, validates the payload, formats the response, sends emails, logs analytics, and renders JSX — all tangled together. The moment you need to change how users are fetched, you risk breaking the email sender. The moment you want to test the validation logic, you have to boot a database. The file has become a single point of failure for the entire application.
🧱 What "a concern" actually means
A concern is a single, clearly bounded reason for code to exist. Showing data to a user is one concern. Fetching that data from a database is a different concern. Deciding whether the data is valid is a third. When code mixes multiple concerns, every change becomes archaeology — you have to excavate what the code is doing before you can touch it.
The classic three-layer mental model maps this directly:
┌─────────────────────────────────┐
│ Presentation Layer │ ← renders UI, formats HTTP responses
│ (routes, components, views) │
└────────────────┬────────────────┘
│ calls
┌────────────────▼────────────────┐
│ Logic / Service Layer │ ← business rules, validation, decisions
│ (services, use-cases, hooks) │
└────────────────┬────────────────┘
│ calls
┌────────────────▼────────────────┐
│ Data Layer │ ← reads and writes storage
│ (repositories, DB queries, ORM)│
└─────────────────────────────────┘
Each layer only talks to the layer directly below it. The presentation layer never writes a SQL query. The data layer never decides if a user is an admin. When a layer needs to change, the others stay still.
Common mistake: Creating the three folders but then reaching across them anyway — e.g., a route handler that imports db directly and runs raw queries. The folder structure is cosmetic if the code ignores the boundary.
🔍 Before: one tangled module
Here is the kind of file that vibe coding often produces naturally. It works. It is also a trap.
// routes/user.js — the 800-line file you don't want to be in
import express from 'express';
import db from '../db.js';
import nodemailer from 'nodemailer';
const router = express.Router();
router.post('/register', async (req, res) => {
const { email, password } = req.body;
// validation — concern 1
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Bad email' });
}
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password too short' });
}
// database — concern 2
const existing = await db.query('SELECT id FROM users WHERE email = $1', [email]);
if (existing.rows.length > 0) {
return res.status(409).json({ error: 'Email taken' });
}
const hashed = await bcrypt.hash(password, 10);
const result = await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id',
[email, hashed]
);
// email — concern 3
const transporter = nodemailer.createTransport({ /* ... */ });
await transporter.sendMail({
to: email,
subject: 'Welcome!',
text: 'Thanks for signing up.',
});
// response formatting — back to concern 1
res.status(201).json({ id: result.rows[0].id, email });
});
This function has four jobs. Adding a second registration pathway (e.g., OAuth) means duplicating all of it. Testing the password validation means importing the database. Changing the email provider means touching the route.
✂️ After: three focused files
Split the concerns into layers and each file becomes obvious and testable in isolation.
// data/userRepository.js — ONLY talks to the database
import db from '../db.js';
import bcrypt from 'bcrypt';
export async function findByEmail(email) {
const result = await db.query('SELECT id, email FROM users WHERE email = $1', [email]);
return result.rows[0] ?? null;
}
export async function createUser(email, password) {
const hash = await bcrypt.hash(password, 10);
const result = await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
[email, hash]
);
return result.rows[0];
}
// services/registrationService.js — ONLY owns the business rules
import * as userRepo from '../data/userRepository.js';
import { sendWelcomeEmail } from '../services/emailService.js';
export async function registerUser(email, password) {
if (!email || !email.includes('@')) throw new Error('INVALID_EMAIL');
if (!password || password.length < 8) throw new Error('WEAK_PASSWORD');
const existing = await userRepo.findByEmail(email);
if (existing) throw new Error('EMAIL_TAKEN');
const user = await userRepo.createUser(email, password);
await sendWelcomeEmail(user.email);
return user;
}
// routes/user.js — ONLY handles HTTP: parse request, call service, format response
import express from 'express';
import { registerUser } from '../services/registrationService.js';
const router = express.Router();
router.post('/register', async (req, res) => {
try {
const user = await registerUser(req.body.email, req.body.password);
res.status(201).json(user);
} catch (err) {
const status = { INVALID_EMAIL: 400, WEAK_PASSWORD: 400, EMAIL_TAKEN: 409 }[err.message] ?? 500;
res.status(status).json({ error: err.message });
}
});
Now registerUser can be called from a CLI script, a test suite, or a different route — without touching the HTTP layer. The repository can be swapped for a different database without touching the business rules. Every file has exactly one reason to change.
Why this matters: When you find a bug in password validation, you open registrationService.js. Full stop. No hunting.
Common mistake: Putting validation inside the repository. Repositories should be dumb — they store and retrieve, nothing more. The decision about what is valid belongs in the service layer.
🧠 The mental model: "a place for everything"
Think of your codebase like a professional kitchen. The prep station does not also serve plates to tables. The walk-in fridge does not also take orders. Every station has a clear job, and the system functions because each piece trusts the others to do theirs. When something breaks, you know exactly which station to check.
When you start a new file, ask one question: What is the single job of this file? Write that job down as a one-sentence comment at the top. If you cannot write it without using the word "and," you are mixing concerns.
// userRepository.js — reads and writes user records to the database. That's it.
// registrationService.js — enforces the rules for creating a new account.
// routes/user.js — translates HTTP requests into service calls and formats responses.
If those comments stay true for the life of the file, you have separated your concerns correctly.
Common mistake: Believing separation is only worth it on large projects. A 200-line app with clean separation is easier to grow than a 200-line app without it. The habit is the thing.
🛠️ Your mission
Open your current project and find the most tangled file — likely a route handler, a page component, or an API endpoint that does too many things. Your job is to split it.
- Read the file and list every distinct job it performs (fetching data, validating input, formatting output, sending notifications, etc.).
- Create one new file per job and move the relevant code into it.
- Wire the files together so the top-level entry point (route or component) only calls the others — it does not do the work itself.
- Confirm the feature still works exactly as before.
✅ You're done when…
- Your Code Review Rubric check passes: each changed file has a single clearly statable job, and no file imports from a layer above it.
- The route or component file contains no raw database queries, no
bcrypt/crypto calls, and no outbound HTTP calls — those live in dedicated modules. - You can describe in one sentence what each new file does, without using the word "and."
- An automated test (even a simple one) can import and call your service function without starting a server or connecting to a database.
➡️ Next: Coupling & Cohesion. Build It Right, Or Don't Build It At All. 🏛️