Skip to content

Express Service Layer

Controllers handle HTTP concerns, req and res, but the actual business rules of your application, how an order is priced, how a user is validated before creation, deserve their own home. This lesson introduces the service layer.

What Is a Service in Express?

A service is a plain JavaScript module (or class) containing business logic with no knowledge of HTTP at all, no req, no res, no status codes. It takes plain data in and returns plain data (or throws an error) out.

Controllers call services and translate the result into an HTTP response. This separation means the same service function can be reused from a controller, a background job, a CLI script, or a test file, all without any Express dependency.

// services/user.service.js
const users = [{ id: 1, email: 'ada@example.com' }];

exports.findById = async (id) => {
  return users.find((u) => u.id === Number(id)) || null;
};

exports.createUser = async ({ email }) => {
  if (users.some((u) => u.email === email)) {
    throw new Error('Email already registered');
  }
  const user = { id: users.length + 1, email };
  users.push(user);
  return user;
};

Notice there is no req, res, or res.status() anywhere, this file could be tested or reused without Express running at all.

Service Layer File Conventions

services/
├── user.service.js
├── order.service.js
└── email.service.js

exports.findById = async (id) => { ... };
exports.create = async (data) => { ... };
exports.update = async (id, data) => { ... };
  • Services take and return plain JavaScript values, never req/res.
  • Business rule violations should throw errors that controllers translate into HTTP responses.
  • Services can call other services (e.g. order.service.js calling email.service.js).
  • This layering makes controllers, services, and data access each independently testable.

Layered Architecture Cheatsheet

A quick summary of how a typical layered Express application is organized.

Layer Knows About HTTP? Knows About the Database? Example File
Router Yes No orders.router.js
Controller Yes No orders.controller.js
Service No Sometimes (or delegates further) order.service.js
Model / Repository No Yes Order.model.js

Connecting a Controller to a Service

The controller's job shrinks to: read input from req, call the service, map the result (or a thrown error) to an HTTP response.

// controllers/user.controller.js
const userService = require('../services/user.service');

exports.create = async (req, res, next) => {
  try {
    const user = await userService.createUser(req.body);
    res.status(201).json(user);
  } catch (err) {
    if (err.message === 'Email already registered') {
      return res.status(409).json({ error: err.message });
    }
    next(err);
  }
};

Testing Services Directly

Because services don't touch Express at all, they can be unit tested by simply calling the function and asserting on the return value or thrown error, no HTTP server or supertest required.

const userService = require('../services/user.service');

test('rejects duplicate emails', async () => {
  await userService.createUser({ email: 'ada@example.com' });
  await expect(userService.createUser({ email: 'ada@example.com' }))
    .rejects.toThrow('Email already registered');
});

Common Mistakes

  • Passing req or res into a service function, coupling business logic to Express unnecessarily.
  • Duplicating the same business rule in multiple controllers instead of centralizing it in one service.
  • Skipping the service layer entirely for small apps, then struggling to refactor once the app grows.
  • Letting services swallow errors silently instead of throwing them for the controller to handle.

Key Takeaways

  • Services hold business logic and never touch req/res directly.
  • Controllers translate between HTTP and services, services translate between input data and business rules.
  • This separation makes business logic reusable and independently unit-testable.
  • Not every tiny app needs a service layer, but it pays off quickly once logic grows past a few lines per route.

Pro Tip

Start extracting a service as soon as a controller function needs an if statement that isn't purely about HTTP status codes, that's usually the moment business logic has started creeping into the wrong layer.