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.