Where Your Business Logic Lives
Services
Services own the domain. Pure-ish functions and classes that don't know about Express.
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 →