Skip to main content
Backend & APIs
⚙️ Backend & APIsLesson 5 of 13

Business Logic & the Service Layer

Separate what your app DOES from your routes and database with a clean service layer.

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:

  1. Create repositories/[resource]Repository.js and move every db.query / ORM call into named functions. No business logic — just data access.
  2. Create services/[resource]Service.js. Move every "what the app decides" rule here. Throw typed errors with statusCode properties.
  3. Slim your controller down to: parse req → call service → write res / call next(err). It should be under fifteen lines.
  4. 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 req or res objects 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. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.