7.2 一个简单的例子
src/services/project/project-service.ts:
import { AppError } from '@/errors/app-error.js';
import type { ProjectModel } from '@/models/project/project-model.js';
import type { Project } from '@/models/project/project.js';
export class ProjectService {
constructor(private readonly projects: ProjectModel) {}
// ===== 查列表 =====
list(name = ''): Project[] {
const all = this.projects.list();
if (!name) return all;
return all.filter((p) => p.name.toLowerCase().includes(name.toLowerCase()));
}
// ===== 查详情 =====
getById(id: number): Project {
const project = this.projects.findById(id);
if (!project) {
throw new AppError('项目不存在', { status: 2 });
}
return project;
}
// ===== 新建 =====
create(body: Record<string, unknown>): Project {
// 1. 提取并校验参数
const name = this.#requireString(body.name, '项目名称不能为空');
const description = typeof body.description === 'string' ? body.description : '';
const status = body.status === 'inactive' ? 'inactive' : 'active';
// 2. 业务规则:名称不能重复
if (this.projects.findByName(name)) {
throw new AppError('项目名称已存在', { status: 2 });
}
// 3. 调用 Model 创建
return this.projects.create({ name, description, status });
}
// ===== 更新 =====
update(id: number, body: Record<string, unknown>): Project {
// 1. 检查是否存在
const current = this.projects.findById(id);
if (!current) {
throw new AppError('项目不存在', { status: 2 });
}
// 2. 提取参数
const name = body.name !== undefined
? this.#requireString(body.name, '项目名称不能为空')
: undefined;
// 3. 业务规则:改名时不能和其他项目重名
if (name && name !== current.name) {
if (this.projects.findByName(name)) {
throw new AppError('项目名称已存在', { status: 2 });
}
}
// 4. 调用 Model 更新
const updated = this.projects.update(id, {
name,
description: typeof body.description === 'string' ? body.description : undefined,
status: body.status === 'inactive' || body.status === 'active' ? body.status : undefined,
});
if (!updated) {
throw new AppError('更新失败', { status: 1 });
}
return updated;
}
// ===== 删除 =====
delete(id: number): void {
const current = this.projects.findById(id);
if (!current) {
throw new AppError('项目不存在', { status: 2 });
}
const success = this.projects.delete(id);
if (!success) {
throw new AppError('删除失败', { status: 1 });
}
}
// ===== 私有工具方法 =====
#requireString(value: unknown, errorMsg: string): string {
const str = typeof value === 'string' ? value.trim() : '';
if (!str) {
throw new AppError(errorMsg, { status: 2 });
}
return str;
}
}
7.3 Service 层 vs Controller 层,参数校验放哪
两者都做,但侧重点不同:
| 层 | 校验什么 | 例子 |
|---|---|---|
| Controller | 格式层面的校验 | ID 是不是数字、必填参数传了没有 |
| Service | 业务层面的校验 | 名称是否重复、状态是否合法、有没有权限 |
Controller 的校验是"这个请求格式对不对",Service 的校验是"这个操作能不能做"。
实际项目中,简单的校验可以都放 Service 层,复杂的可以用 zod 或 class-validator 做 Schema 校验。
7.4 为什么入参用 Record<string, unknown>
你可能注意到了,create 和 update 的入参不是具体的类型,而是 Record<string, unknown>。
原因是:请求体是不可信的,你不能假设前端传进来的数据结构和你的类型定义一致。
// ❌ 不要这样写
create(input: CreateProjectInput): Project {
// input.name 一定是 string 吗?不一定!
// 前端可能传 number、undefined、甚至 null
}
// ✅ 应该这样写
create(body: Record<string, unknown>): Project {
const name = this.#requireString(body.name, '名称不能为空');
// 经过校验后,name 一定是合法的 string
}
TypeScript 的类型在运行时是不存在的。如果你把 req.body 直接断言成某个类型,等于把安全门全打开了。
正确做法:从不可信输入(body/query/params)中提取字段,逐个校验,校验通过后再组装成可信的数据。
7.5 多 Model 协调的例子
Service 层的一个重要作用是协调多个 Model。
比如"删除项目时,要同时删除项目下的所有任务":
export class ProjectService {
constructor(
private readonly projects: ProjectModel,
private readonly tasks: TaskModel, // 另一个 Model
) {}
delete(id: number): void {
const project = this.projects.findById(id);
if (!project) {
throw new AppError('项目不存在', { status: 2 });
}
// 删除项目下的所有任务
this.tasks.deleteByProjectId(id);
// 删除项目
this.projects.delete(id);
}
}
这种涉及多个数据模型的操作,就是 Service 层的事,不应该放在 Controller 里。
在数据库场景下,这还涉及事务(要么都成功,要么都失败),也是 Service 层管理的。
7.6 抛出错误而不是返回 null
Service 层遇到错误应该 throw,而不是返回 null 或 { error: ... }。
// ❌ 返回错误对象
getById(id: number): Project | { error: string } {
const project = this.projects.findById(id);
if (!project) return { error: '项目不存在' };
return project;
}
// ✅ 直接抛
getById(id: number): Project {
const project = this.projects.findById(id);
if (!project) {
throw new AppError('项目不存在', { status: 2 });
}
return project;
}
为什么 throw 更好:
- 不会忘处理:返回 null 或 error 对象,调用方忘了判断就会出 bug;throw 了不 catch 就会报错,很显眼
- 错误可以冒泡:一层层往上抛,最后由全局错误中间件统一处理
- 类型干净:返回类型就是
Project,不用带| null | Error各种联合类型
Controller 层不需要 try/catch,Express 5 会自动把 async 里的错误传给错误处理中间件。
7.7 Service 方法的命名约定
| 操作 | 推荐命名 | 示例 |
|---|---|---|
| 查询列表 | list / find | list(), listByStatus() |
| 查询单个 | getById / findById | getById(id) |
| 创建 | create | create(data) |
| 更新 | update | update(id, data) |
| 删除 | delete / remove | delete(id) |
| 业务操作 | 动词开头 | assignUser(), toggleStatus() |
保持命名一致,看方法名就知道做什么的。
7.8 小结
- Service 层是业务核心,负责参数校验、业务规则、多 Model 协调
- 入参用
Record<string, unknown>,因为请求体不可信,要逐个校验 - 遇到错误 throw AppError,不要返回 null 或错误对象
- 多 Model 的操作放在 Service 层,Controller 不直接操作多个 Model
- Service 层不依赖 HTTP,换框架不用改 Service