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. ๐๏ธ