Business Logic & the Service Layer
Stage 3 · Backend & APIs · B.U.I.L.D. letter: U
If your route handler can't be explained in one sentence, it's doing three jobs — and the first thing to break will be the part you care about most.
⚠️ The vibe trap
When you're in full flow, it's tempting to slam everything into one route handler: grab the body, check the rules, hit the database, format the response, done. It ships. It works. You move on. Then, three weeks later, you need the same logic in a background job and a webhook handler, and you copy-paste the whole block — now you have three copies of the same rules drifting apart silently. Fat controllers don't rot slowly; they collapse all at once, usually right before a demo.
🏗️ The Three Layers (and Why Each One Has One Job)
Every backend request travels through three distinct concerns. When they blur together you get spaghetti; when they're separated you get a system you can test, reuse, and change with confidence.
Route / Controller — Translates HTTP into function calls. It reads req, calls a service function, then writes to res. That's it. No rules, no SQL.
Service — Holds everything the business cares about: "a user can't place an order if their account is suspended," "an invoice amount must be positive," "sending a confirmation email is part of completing a purchase." This is the layer your CEO would recognize as "what the app does."
Repository / Data-access — The only layer allowed to touch a database client, ORM, or external storage API. It speaks rows and documents; it knows nothing about HTTP status codes or business rules.
// Mental model in three lines:
router.post('/orders', orderController.create); // Layer 1: HTTP translation
// └─ calls ──> orderService.createOrder(data) // Layer 2: business rules
// └─ calls ──> orderRepo.insert(row) // Layer 3: database
Why it matters: Each layer can be tested independently. A service function is a plain JavaScript function — you can call it in a unit test with no HTTP server and no real database, just mocked repo calls.
Common mistake: Thinking the service layer is only for "complex" apps. Even a five-route API benefits immediately — the moment you add a second entry point (cron job, webhook, CLI script) you'll thank yourself.
🍝 Before: The Fat Controller (Don't Ship This)
Here is a real-world fat route. You've probably written something exactly like it. Every line is reasonable in isolation; together they're a maintenance trap.
// routes/orders.js — BEFORE (fat controller, do not copy)
router.post('/orders', async (req, res) => {
const { userId, items, couponCode } = req.body;
// --- validation (should be middleware or service) ---
if (!userId || !items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'userId and items[] are required' });
}
// --- business rule: check account standing (should be service) ---
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
if (!user.rows[0]) return res.status(404).json({ error: 'User not found' });
if (user.rows[0].status === 'suspended') {
return res.status(403).json({ error: 'Account suspended' });
}
// --- business rule: apply coupon (should be service) ---
let discount = 0;
if (couponCode) {
const coupon = await db.query(
'SELECT * FROM coupons WHERE code = $1 AND expires_at > NOW()',
[couponCode]
);
if (coupon.rows[0]) discount = coupon.rows[0].discount_pct;
}
// --- calculate total (should be service) ---
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const total = subtotal * (1 - discount / 100);
// --- write to DB (should be repository) ---
const order = await db.query(
'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING *',
[userId, total, 'pending']
);
res.status(201).json(order.rows[0]);
});
This is ~40 lines. Now imagine adding a webhook that creates an order from a Stripe event, or a cron job that auto-renews subscriptions. You'll copy all of this — bugs and all.
✂️ After: Controller + Service + Repository
Split the same logic into its three proper homes. The total line count barely changes; what changes is that every piece is now independently testable and reusable.
Step 1 — The Repository (only SQL lives here)
// repositories/orderRepository.js
const db = require('../db');
async function insertOrder(userId, total) {
const result = await db.query(
'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING *',
[userId, total, 'pending']
);
return result.rows[0];
}
async function findUserById(userId) {
const result = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
return result.rows[0] ?? null;
}
async function findActiveCoupon(code) {
const result = await db.query(
'SELECT * FROM coupons WHERE code = $1 AND expires_at > NOW()',
[code]
);
return result.rows[0] ?? null;
}
module.exports = { insertOrder, findUserById, findActiveCoupon };
Step 2 — The Service (business rules live here, nothing else)
// services/orderService.js
const orderRepo = require('../repositories/orderRepository');
async function createOrder({ userId, items, couponCode }) {
// Rule: items must be a non-empty array
if (!Array.isArray(items) || items.length === 0) {
const err = new Error('items[] must be a non-empty array');
err.statusCode = 400;
throw err;
}
// Rule: user must exist and be active
const user = await orderRepo.findUserById(userId);
if (!user) {
const err = new Error('User not found');
err.statusCode = 404;
throw err;
}
if (user.status === 'suspended') {
const err = new Error('Account is suspended');
err.statusCode = 403;
throw err;
}
// Rule: apply coupon if valid
let discount = 0;
if (couponCode) {
const coupon = await orderRepo.findActiveCoupon(couponCode);
if (coupon) discount = coupon.discount_pct;
}
// Rule: calculate total
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const total = subtotal * (1 - discount / 100);
return orderRepo.insertOrder(userId, total);
}
module.exports = { createOrder };
Step 3 — The Controller (HTTP translation only — thin as possible)
// controllers/orderController.js
const orderService = require('../services/orderService');
async function create(req, res, next) {
try {
const order = await orderService.createOrder(req.body);
res.status(201).json(order);
} catch (err) {
next(err); // your error middleware handles status codes
}
}
module.exports = { create };
// routes/orders.js — AFTER (thin route file)
const router = require('express').Router();
const orderController = require('../controllers/orderController');
router.post('/orders', orderController.create);
module.exports = router;
Notice the controller has no knowledge of SQL, no if-chains of business rules, and no repeated code. It receives HTTP, calls a service, and returns HTTP.
Mental model: The controller is a phone operator — it connects the call. The service is the expert who handles the call. The repository is the filing cabinet the expert consults.
Common mistake: Putting the statusCode property on errors inside the repository. Repositories don't know what HTTP is. Only the service (or error middleware) should assign status codes.
🧪 Why the Service Layer is the Testability Gift
Because orderService.createOrder is a plain async function, you can test every business rule without spinning up an Express server or a real database.
// services/orderService.test.js (Jest / Vitest)
const orderService = require('./orderService');
const orderRepo = require('../repositories/orderRepository');
// Mock the entire repository module
jest.mock('../repositories/orderRepository');
describe('orderService.createOrder', () => {
test('throws 403 when user is suspended', async () => {
orderRepo.findUserById.mockResolvedValue({ id: '1', status: 'suspended' });
await expect(
orderService.createOrder({ userId: '1', items: [{ price: 10, qty: 1 }] })
).rejects.toMatchObject({ statusCode: 403 });
});
test('applies coupon discount correctly', async () => {
orderRepo.findUserById.mockResolvedValue({ id: '1', status: 'active' });
orderRepo.findActiveCoupon.mockResolvedValue({ discount_pct: 20 });
orderRepo.insertOrder.mockResolvedValue({ id: 'order-1', total: 80 });
const order = await orderService.createOrder({
userId: '1',
items: [{ price: 100, qty: 1 }],
couponCode: 'SAVE20',
});
expect(order.total).toBe(80);
// insertOrder was called with the discounted amount
expect(orderRepo.insertOrder).toHaveBeenCalledWith('1', 80);
});
});
No HTTP. No database. Runs in milliseconds. And when the "suspended account" rule changes — say, suspended users can still view but not place — you change one function in one file and the test tells you immediately if something broke.
Common mistake: Testing the controller instead of the service. Controllers are so thin there's almost nothing to test. Business logic lives in the service — that's where your tests belong.
🛠️ Your Mission
Pick one route in your current app that does more than three things (validation, a rule, and a DB call is already three things). Refactor it:
- Create
repositories/[resource]Repository.jsand move everydb.query/ ORM call into named functions. No business logic — just data access. - Create
services/[resource]Service.js. Move every "what the app decides" rule here. Throw typed errors withstatusCodeproperties. - Slim your controller down to: parse
req→ call service → writeres/ callnext(err). It should be under fifteen lines. - Wire the route file to the controller. Nothing in the route file but
router.verb(path, controller.method).
Bonus: write one unit test for the most important rule in your new service function.
✅ You're done when…
- Your route handler contains no SQL strings, no ORM calls, and no if-chains enforcing business rules — it only calls a service function and writes the response (Code Review Rubric).
- Your service function can be called from a test file (or a hypothetical cron job) with no
reqorresobjects in scope — it takes plain data in and returns plain data (or throws a typed error) out (Pre-Ship Checklist). - Your repository functions each do exactly one database operation, accept plain arguments, and return plain JavaScript objects — no HTTP concepts leak into them (Code Review Rubric).
➡️ Next: Talking to the Database. Build It Right, Or Don't Build It At All. 🏛️