Services

Where Your Business Logic Lives

Services

Services own the domain. Pure-ish functions and classes that don't know about Express.

4 min read Level 2/5 #express#services#architecture
What you'll learn
  • Author a service module
  • Throw typed domain errors
  • Test services without Express

A service owns business logic. It doesn’t import from Express, doesn’t know about HTTP, doesn’t know about req or res. That keeps it testable, reusable, and movable to a CLI or queue worker without rewriting.

A Service Module

// src/services/users.js
import { db } from "../db/client.js";
import { hashPassword } from "../lib/passwords.js";

export class EmailTakenError extends Error {
  constructor() {
    super("email already in use");
    this.code = "USER_EMAIL_TAKEN";
  }
}

export async function list({ page = 1, limit = 20 } = {}) {
  return db.users.findMany({
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: "desc" },
  });
}

export async function findById(id) {
  return db.users.findUnique({ where: { id } });
}

export async function create({ email, password, name }) {
  const existing = await db.users.findUnique({ where: { email } });
  if (existing) throw new EmailTakenError();

  return db.users.create({
    data: {
      email,
      name,
      passwordHash: await hashPassword(password),
    },
  });
}

export async function update(id, patch) {
  return db.users.update({ where: { id }, data: patch });
}

export async function remove(id) {
  return db.users.delete({ where: { id } });
}

Notice: no req, no res, no Express. Just functions.

Typed Domain Errors

Services throw domain-meaningful errors. Controllers translate them to HTTP.

// service throws
throw new EmailTakenError();

// controller catches
catch (err) {
  if (err instanceof EmailTakenError) {
    return res.status(409).json({ error: { code: err.code } });
  }
  next(err);
}

This split is what makes services reusable. A CLI command that creates a user can catch EmailTakenError and print a friendly message — totally separate from any HTTP code.

Testing

Services test cleanly because they’re decoupled:

import { test, expect } from "vitest";
import * as users from "../src/services/users.js";

test("creates a user", async () => {
  const user = await users.create({ email: "ada@x.com", password: "secret123", name: "Ada" });
  expect(user.email).toBe("ada@x.com");
});

test("rejects duplicate email", async () => {
  await users.create({ email: "linus@x.com", password: "secret", name: "Linus" });
  await expect(users.create({ email: "linus@x.com", password: "x", name: "L" }))
    .rejects.toThrow(users.EmailTakenError);
});

No HTTP layer to spin up. No supertest. Just call the function.

When To Skip Services

For a CRUD endpoint that just reads/writes one row, splitting controller and service can feel like overkill. That’s fine — start with the controller doing the DB work. Extract a service when:

  • The same logic is needed in two controllers
  • The controller body is >20 lines
  • You want to test logic without Express

End Result

Three layers:

HTTP request → Route → Controller → Service → DB
              (URL) (parse input)  (logic)

Each layer is replaceable without touching the others.

Validation →