Services层设计
要点
- Services 层的核心职责是承载业务逻辑——它接收来自 Routes 层的调用,按业务规则编排数据,再把结果交还给 Routes 层或传给 Repositories 层
- 判断一段代码是否属于 Service,可以问一个问题:「如果把 HTTP 换掉,这段逻辑还有意义吗?」有意义就属于 Service,没意义就留在 Routes 层
- Service 的组织方式有两条路——函数式和类式。小项目用函数式就够了,需要依赖注入或共享状态时再切类式
- Service 之间的依赖通过参数传入,不要在 Service 内部直接 import 另一个 Service 的实例
- 错误处理在 Service 层需要区分「业务错误」和「系统错误」,前者是预期内的规则冲突,后者是预期外的基础设施故障
- 事务边界由 Service 层控制,Repository 只负责单条 SQL 的原子性,跨 Repository 的一致性由 Service 协调
- 测试 Service 时 mock 掉 Repository 层,让测试只验证业务逻辑本身
1. Service 层解决什么问题
02 把项目按职责拆成了 routes/、middleware/、db/、lib/ 几个目录。路由文件里直接写 db.select().from(usersTable) 确实能跑。但当业务规则逐渐变多——注册前要检查邮箱是否重复、密码要加盐哈希、注册后要发验证邮件、不同角色有不同的创建权限——这些逻辑继续留在路由处理函数里,会让单个函数膨胀到上百行。
Services 层的作用是把「做什么」和「怎么做」分开。Routes 层只关心「接收请求、返回响应」,Services 层负责「按业务规则把事做完」。
// src/routes/users.ts
// Routes 层:只处理 HTTP 协议相关的事
import { userService } from '../services/user'
export const usersApp.get('/', authMiddleware, async (c) => {
const body = await c.req.json()
// 路由层不关心注册逻辑的细节,只负责参数传递和响应格式化
const result = await userService.register(body.email, body.password)
return c.json(result, 201)
})// src/services/user.ts
// Service 层:承载业务规则
import { userRepository } from '../repositories/user'
import { hashPassword } from '../lib/password'
async function register(email: string, password: string) {
// 业务规则 1:检查邮箱是否已注册
const existing = await userRepository.findByEmail(email)
if (existing) {
throw new BusinessError('EMAIL_ALREADY_EXISTS', '该邮箱已被注册')
}
// 业务规则 2:密码加盐哈希
const hashedPassword = await hashPassword(password)
// 业务规则 3:创建用户
const user = await userRepository.create({
email,
password: hashedPassword,
})
return { id: user.id, email: user.email }
}这个分工带来一个直接的好处: Routes 层的处理函数保持简短,业务规则集中在 Service 层,修改业务逻辑时不需要在路由文件里翻找。
2. Service 层的边界
判断一段代码是否属于 Service,有一个比较实用的标准:如果把 HTTP 协议换掉(比如改成 gRPC、消息队列消费、定时任务),这段逻辑是否仍然需要? 需要的话就属于 Service,不需要的话就留在 Routes 层。
属于 Service 层的职责
- 业务规则校验:邮箱是否重复、订单金额是否超过限额、用户是否有权限执行某操作
- 数据编排:一个操作需要跨多个 Repository 读写数据时,由 Service 协调调用顺序
- 数据转换:把数据库的行记录转换成业务对象或 DTO,把外部 API 返回的格式转换成内部模型
- 外部服务调用:发邮件、调第三方支付、推送通知——这些动作的业务编排属于 Service
- 领域事件触发:注册完成后发验证邮件、订单支付完成后更新库存
不属于 Service 层的职责
- HTTP 协议细节:状态码选择、请求头解析、响应体格式化——这些是 Routes 层的事
- 数据库查询语句:
SELECT、INSERT这类具体 SQL 由 Repository 层负责,Service 只调用 Repository 暴露的方法 - 请求参数校验:参数格式校验(类型、必填、长度)在 Routes 层通过 validator 中间件完成,Service 接收的参数已经是合法值
- 认证与授权的前置判断:JWT 解析、Token 验证由中间件完成,Service 接收的是已确认身份的用户信息
// src/services/user.ts
// ✅ Service 做业务校验
async function updateRole(userId: number, newRole: string) {
const user = await userRepository.findById(userId)
if (!user) {
throw new BusinessError('USER_NOT_FOUND', '用户不存在')
}
// 业务规则:只有 admin 才能被设置为 moderator
if (newRole === 'moderator' && user.role !== 'admin') {
throw new BusinessError('INSUFFICIENT_ROLE', '权限不足')
}
return userRepository.updateRole(userId, newRole)
}// src/routes/users.ts
// ✅ Route 做 HTTP 格式校验
import { z } from 'zod'
import { validator } from 'hono/validator'
const updateRoleSchema = z.object({
userId: z.number().int().positive(),
newRole: z.enum(['admin', 'moderator', 'user']),
})
app.post(
'/users/role',
validator('json', (value, c) => {
const result = updateRoleSchema.safeParse(value)
if (!result.success) {
return c.json({ error: 'invalid input' }, 400)
}
return result.data
}),
async (c) => {
const { userId, newRole } = c.req.valid('json')
// 参数格式校验已在 validator 完成,Service 只做业务规则校验
const result = await userService.updateRole(userId, newRole)
return c.json(result)
}
)3. 函数式与类式 Service
Service 的组织方式有两种常见选择。两种没有绝对优劣,按项目规模和团队偏好来定。
函数式 Service
每个 Service 是一个文件,导出若干函数。函数接收需要的依赖作为参数,或通过闭包访问共享的 Repository 实例。
// src/services/user.ts
import { userRepository } from '../repositories/user'
import { emailService } from './email'
// 直接 import 其他模块的实例
export async function register(email: string, password: string) {
const existing = await userRepository.findByEmail(email)
if (existing) {
throw new BusinessError('EMAIL_ALREADY_EXISTS', '该邮箱已被注册')
}
const hashedPassword = await hashPassword(password)
const user = await userRepository.create({ email, password: hashedPassword })
// 调用其他 Service
await emailService.sendVerification(user.email)
return { id: user.id, email: user.email }
}
export async function getById(id: number) {
return userRepository.findById(id)
}函数式的优点是简单直接——文件即模块,不需要实例化过程,调用时 import { register } from '../services/user' 就行。适合 Service 的依赖关系比较固定、不需要在测试和运行时切换实现的场景。
类式 Service
用 class 封装 Service,依赖通过构造函数注入。
// src/services/user.ts
import type { UserRepository } from '../repositories/user'
import type { EmailService } from './email'
export class UserService {
// 依赖通过构造函数注入,不在内部直接 import
constructor(
private userRepository: UserRepository,
private emailService: EmailService
) {}
async register(email: string, password: string) {
const existing = await this.userRepository.findByEmail(email)
if (existing) {
throw new BusinessError('EMAIL_ALREADY_EXISTS', '该邮箱已被注册')
}
const hashedPassword = await hashPassword(password)
const user = await this.userRepository.create({
email,
password: hashedPassword,
})
await this.emailService.sendVerification(user.email)
return { id: user.id, email: user.email }
}
async getById(id: number) {
return this.userRepository.findById(id)
}
}// src/services/container.ts
// 统一的 Service 实例工厂
import { UserService } from './user'
import { EmailService } from './email'
import { userRepository } from '../repositories/user'
// 在应用启动时组装依赖图
const emailService = new EmailService({ apiKey: process.env.EMAIL_API_KEY! })
export const userService = new UserService(userRepository, emailService)类式的优点是依赖关系显式声明,测试时可以轻松替换 Repository 为 mock 实现。代价是需要一个组装依赖的地方(通常叫 container 或 di)。
如何选择
项目里 Service 不超过五个、依赖关系简单时,函数式足够。当出现以下情况时,切到类式会更顺畅:
- 同一个 Service 在测试和生产环境需要不同的 Repository 实现
- 多个 Service 共享一组依赖,需要统一管理
- 团队习惯面向接口编程,需要明确的类型约束
两种风格也可以共存。核心 Service(如用户、订单)用类式,辅助 Service(如格式化、计算)用函数式。
4. 依赖注入
Service 层需要访问 Repository、外部 API 客户端、配置等外部依赖。依赖的获取方式直接影响代码的可测试性和可维护性。
构造函数注入
类式 Service 最标准的做法,前面已经展示过。构造函数声明所有依赖,实例化时一次性传入:
// src/services/order.ts
import type { OrderRepository } from '../repositories/order'
import type { UserRepository } from '../repositories/user'
import type { PaymentClient } from '../clients/payment'
export class OrderService {
constructor(
private orderRepo: OrderRepository,
private userRepo: UserRepository,
private paymentClient: PaymentClient
) {}
async createOrder(userId: number, items: OrderItem[]) {
const user = await this.userRepo.findById(userId)
if (!user) throw new BusinessError('USER_NOT_FOUND', '用户不存在')
const total = calculateTotal(items)
const payment = await this.paymentClient.charge(user.paymentMethodId, total)
return this.orderRepo.create({
userId,
items,
total,
paymentId: payment.id,
})
}
}工厂函数注入
函数式 Service 也可以用类似思路——用一个工厂函数接收依赖,返回绑定好依赖的函数集合:
// src/services/user.ts
import type { UserRepository } from '../repositories/user'
import type { EmailService } from './email'
export function createUserService(deps: {
userRepo: UserRepository
emailService: EmailService
}) {
const { userRepo, emailService } = deps
async function register(email: string, password: string) {
const existing = await userRepo.findByEmail(email)
if (existing) {
throw new BusinessError('EMAIL_ALREADY_EXISTS', '该邮箱已被注册')
}
const hashedPassword = await hashPassword(password)
const user = await userRepo.create({ email, password: hashedPassword })
await emailService.sendVerification(user.email)
return { id: user.id, email: user.email }
}
async function getById(id: number) {
return userRepo.findById(id)
}
return { register, getById }
}// src/services/container.ts
export const userService = createUserService({
userRepo: userRepository,
emailService,
})工厂函数的思路和构造函数注入一致——依赖从外部传入,不在内部直接获取——只是用闭包替代了 class 的私有字段。
不要这样做
// src/services/user.ts
// ❌ 在 Service 内部直接创建 Repository 实例
import { drizzle } from 'drizzle-orm/d1'
export async function register(email: string) {
// Service 直接创建了数据库连接,测试时无法替换
const db = drizzle(c.env.DB)
const existing = await db.select().from(usersTable).where(...)
}这样做的问题是:Service 和具体的数据库实现绑死了,测试时没法把数据库替换成 mock,部署到不同环境时也没法切换数据源。
5. Service 之间的组合
一个复杂操作通常需要多个 Service 协作。比如「用户下单」涉及 OrderService、UserService、InventoryService、PaymentService、NotificationService。
直接注入
最常见的做法是让需要协调的 Service 持有其他 Service 的引用:
// src/services/order.ts
export class OrderService {
constructor(
private orderRepo: OrderRepository,
private userService: UserService,
private inventoryService: InventoryService,
private paymentService: PaymentService,
private notificationService: NotificationService
) {}
async placeOrder(userId: number, items: OrderItem[]) {
// 1. 验证用户
const user = await this.userService.getById(userId)
if (!user) throw new BusinessError('USER_NOT_FOUND', '用户不存在')
// 2. 检查库存
await this.inventoryService.reserve(items)
// 3. 扣款
const payment = await this.paymentService.charge(user.paymentMethodId, calculateTotal(items))
// 4. 创建订单记录
const order = await this.orderRepo.create({ userId, items, paymentId: payment.id })
// 5. 发送通知
await this.notificationService.sendOrderConfirmation(user.email, order)
return order
}
}编排 vs 委托
上面的 placeOrder 是一个「编排者」——它知道完整的业务流程,按顺序调用各个 Service。每个被调用的 Service 只负责自己领域的逻辑。
需要注意「编排」和「委托」的区别。编排是 Service A 知道要调用 B、C、D,按顺序组织调用。委托是 Service A 把一整块业务交给 Service B,不关心 B 内部怎么做。
// ✅ 编排:OrderService 知道下单流程的步骤
async placeOrder(userId: number, items: OrderItem[]) {
await this.userService.validate(userId)
await this.inventoryService.reserve(items)
const payment = await this.paymentService.charge(...)
const order = await this.orderRepo.create(...)
await this.notificationService.sendConfirmation(...)
return order
}
// ✅ 委托:OrderService 只关心订单创建,库存和支付由各自的 Service 全权处理
async createOrder(userId: number, items: OrderItem[], paymentMethodId: string) {
const order = await this.orderRepo.create({ userId, items })
return order
}编排放在哪里,取决于业务变化的频率。如果下单流程经常调整(加步骤、改顺序),放在一个专门的编排 Service 里比较合适。如果各步骤独立变化,编排逻辑放在 Routes 层也可以接受。
6. Service 层的错误处理
Service 层的错误需要区分两类:业务错误(预期内)和系统错误(预期外)。
业务错误
业务错误是指业务规则被触发时的正常失败——邮箱已注册、余额不足、库存不够。这类错误是业务流程的一部分,Routes 层收到后应该返回合适的 HTTP 状态码(4xx)给客户端。
// src/errors.ts
// 业务错误:有明确的错误码和消息,Routes 层可以据此构造响应
export class BusinessError extends Error {
constructor(
public code: string,
message: string
) {
super(message)
this.name = 'BusinessError'
}
}系统错误
系统错误是指预期外的故障——数据库连接断开、第三方 API 超时、内存不足。这类错误通常意味着基础设施出了问题,Routes 层收到后应该返回 500 并触发告警。
// src/services/payment.ts
export class PaymentService {
constructor(private client: PaymentClient) {}
async charge(methodId: string, amount: number): Promise<Payment> {
try {
return await this.client.charge(methodId, amount)
} catch (error) {
// 区分错误类型
if (error instanceof PaymentDeclinedError) {
// 业务错误:支付方式被拒绝(余额不足、卡过期等)
throw new BusinessError('PAYMENT_DECLINED', '支付被拒绝,请更换支付方式')
}
// 系统错误:网络超时、服务不可用
// 不在 Service 层吞掉,让上层统一处理
throw error
}
}
}// src/middleware/error-handler.ts
// Routes 层的统一错误处理
export async function errorHandler(c: Context, next: Next) {
try {
await next()
} catch (error) {
if (error instanceof BusinessError) {
// 业务错误:返回 4xx,不需要记录堆栈
return c.json({ code: error.code, message: error.message }, 400)
}
// 系统错误:记录日志、触发告警、返回 500
console.error('[Unexpected Error]', error)
return c.json({ message: 'Internal Server Error' }, 500)
}
}这种分层处理让 Service 层专注于「这个错误属于什么性质」,Routes 层专注于「用什么 HTTP 状态码回应」。
7. 事务管理
当一个业务操作需要修改多张表的数据时,需要保证这些修改要么全部成功,要么全部回滚。事务的边界由 Service 层控制。
// src/services/transfer.ts
export class TransferService {
constructor(
private accountRepo: AccountRepository,
private transactionLogRepo: TransactionLogRepository
) {}
async transfer(fromId: number, toId: number, amount: number) {
// 事务边界由 Service 控制
// 使用 tx 参数让多个 Repository 操作共享同一个事务
return this.accountRepo.withTransaction(async (tx) => {
// 在同一个事务内完成扣款和入账
await this.accountRepo.deduct(fromId, amount, tx)
await this.accountRepo.add(toId, amount, tx)
await this.transactionLogRepo.record(
{ fromId, toId, amount, timestamp: new Date() },
tx
)
})
}
}Repository 层提供 withTransaction 方法,接收一个回调函数,把事务对象 tx 传进去。Service 层在回调内组织多次 Repository 调用,保证它们在同一事务内执行。
如果 Service 调用了另一个 Service,而那个 Service 也需要事务,有两种处理方式:
// 方式一:把事务对象显式传递
async placeOrder(userId: number, items: OrderItem[]) {
return this.orderRepo.withTransaction(async (tx) => {
await this.inventoryService.reserve(items, tx)
const order = await this.orderRepo.create({ userId, items }, tx)
return order
})
}
// 方式二:Service 内部各管各的事务(不推荐用于需要跨 Service 一致性的场景)
async placeOrder(userId: number, items: OrderItem[]) {
await this.inventoryService.reserve(items) // 独立事务
const order = await this.orderRepo.create({ userId, items }) // 独立事务
return order
}方式一能保证跨 Service 操作的原子性,但会让 Service 接口多出 tx 参数。方式二更简洁,但如果 reserve 成功而 create 失败,库存已经扣了却没有订单记录。选择哪种方式取决于业务对一致性的要求。
8. 测试 Service
Service 层的测试重点是业务逻辑本身——规则是否正确、边界条件是否处理、调用顺序是否符合预期。Repository 和外部服务的实际行为不在测试范围内,用 mock 替换掉。
函数式 Service 的测试
// tests/services/user.test.ts
import { describe, it, expect, vi } from 'vitest'
import { createUserService } from '../../src/services/user'
describe('UserService.register', () => {
it('邮箱已存在时抛出 EMAIL_ALREADY_EXISTS', async () => {
const mockUserRepo = {
findByEmail: vi.fn().mockResolvedValue({ id: 1, email: '[email protected]' }),
create: vi.fn(),
}
const mockEmailService = {
sendVerification: vi.fn(),
}
const userService = createUserService({
userRepo: mockUserRepo,
emailService: mockEmailService,
})
await expect(userService.register('[email protected]', 'password'))
.rejects.toThrow('EMAIL_ALREADY_EXISTS')
// 确认 create 没有被调用
expect(mockUserRepo.create).not.toHaveBeenCalled()
})
it('注册成功后发送验证邮件', async () => {
const mockUserRepo = {
findByEmail: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: 1, email: '[email protected]' }),
}
const mockEmailService = {
sendVerification: vi.fn(),
}
const userService = createUserService({
userRepo: mockUserRepo,
emailService: mockEmailService,
})
await userService.register('[email protected]', 'password')
expect(mockEmailService.sendVerification).toHaveBeenCalledWith('[email protected]')
})
})类式 Service 的测试
类式 Service 的测试思路一样,只是实例化方式不同:
// tests/services/order.test.ts
import { describe, it, expect, vi } from 'vitest'
import { OrderService } from '../../src/services/order'
describe('OrderService.placeOrder', () => {
it('库存不足时抛出 OUT_OF_STOCK', async () => {
const mockInventoryService = {
reserve: vi.fn().mockRejectedValue(
new BusinessError('OUT_OF_STOCK', '库存不足')
),
}
const orderService = new OrderService(
mockOrderRepo,
mockUserService,
mockInventoryService,
mockPaymentService,
mockNotificationService
)
await expect(orderService.placeOrder(1, [{ sku: 'ABC', qty: 100 }]))
.rejects.toThrow('OUT_OF_STOCK')
})
})测试 Service 时,mock 的粒度是 Repository 接口,不是数据库。这样做的好处是:
- 测试不依赖数据库运行环境,速度快
- 测试只验证 Service 的逻辑——它是否正确地调用了 Repository、处理了返回值、抛出了预期的错误
- Repository 自身的正确性由 Repository 层的测试负责
9. 常见反模式
贫血 Service
Service 只是透传 Repository 的调用,没有任何业务逻辑:
// ❌ 贫血 Service——只是 Repository 的搬运工
export class UserService {
constructor(private userRepo: UserRepository) {}
async getById(id: number) {
return this.userRepo.findById(id)
}
async create(data: CreateUserInput) {
return this.userRepo.create(data)
}
async update(id: number, data: UpdateUserInput) {
return this.userRepo.update(id, data)
}
}如果 Service 里没有任何业务规则、数据转换或跨 Repository 编排,那这个 Service 可能没有必要存在。Routes 层直接调用 Repository 反而更直接。
判断标准:Service 里的每个方法都是单行透传吗?如果是,考虑去掉这层,或者把散落在 Routes 层的业务逻辑下沉到 Service。
上帝 Service
一个 Service 承担了太多职责,文件膨胀到上千行:
// ❌ 上帝 Service——什么都做
export class AppService {
// 用户相关
async registerUser(...) { ... }
async loginUser(...) { ... }
async resetPassword(...) { ... }
// 订单相关
async createOrder(...) { ... }
async cancelOrder(...) { ... }
async refundOrder(...) { ... }
// 商品相关
async createProduct(...) { ... }
async updatePrice(...) { ... }
// 通知相关
async sendEmail(...) { ... }
async sendSMS(...) { ... }
async pushNotification(...) { ... }
}上帝 Service 的特征是方法数超过 15-20 个,或涉及多个不相关的业务领域。按业务领域拆分——UserService、OrderService、ProductService、NotificationService——每个 Service 只负责自己领域的逻辑。
循环依赖
Service A 依赖 Service B,Service B 又依赖 Service A:
// ❌ UserService 依赖 OrderService
export class UserService {
constructor(private orderService: OrderService) {}
async getUserOrderCount(userId: number) {
return this.orderService.countByUserId(userId)
}
}
// ❌ OrderService 依赖 UserService
export class OrderService {
constructor(private userService: UserService) {}
async createOrder(userId: number, items: OrderItem[]) {
const user = await this.userService.getById(userId)
// ...
}
}循环依赖通常是 Service 职责划分不当的信号。解决方式:
- 提取共享逻辑到第三个 Service:如果
getUserOrderCount需要同时访问用户和订单信息,创建一个UserOrderQueryService - 把查询下沉到 Repository:
getUserOrderCount本质是一个查询,可以直接在 Repository 层用一条 SQL 完成 - 事件驱动:OrderService 创建订单后发布一个事件,UserService 订阅这个事件来更新统计,而不是被直接调用
在 Service 层处理 HTTP 细节
// ❌ Service 里不应该出现 c.json()、c.status() 这类 HTTP 操作
export class UserService {
async register(email: string, password: string, c: Context) {
if (!email) {
return c.json({ error: 'email required' }, 400)
}
// ...
return c.json({ id: user.id }, 201)
}
}Service 层的返回值应该是业务数据(对象、数组),不是 HTTP Response。把 Context 传进 Service 会让 Service 和 Hono 框架耦合,无法在非 HTTP 场景下复用。
10. 何时拆分 Service
一个 Service 在以下情况下应该考虑拆分:
- 方法数超过 10-15 个:单个文件超过 300 行时,阅读和维护成本明显上升
- 涉及多个业务领域:UserService 里出现了订单逻辑,说明它可能该叫 UserOrderService,或者订单逻辑应该挪到 OrderService
- 依赖注入过多:构造函数超过 5 个参数,说明这个 Service 承担了太多协调工作
- 不同部分的变更频率差异大:用户注册逻辑每周改,用户权限逻辑半年改一次——它们不应该在同一个文件里
拆分时不需要一步到位。先按业务领域拆成独立的 Service 文件,再逐步调整依赖关系。
// 拆分前:一个 UserService 处理所有用户相关逻辑
// src/services/user.ts
// 拆分后:按职责细分
// src/services/user-command.ts — 写操作(注册、修改、删除)
// src/services/user-query.ts — 读操作(查询、统计、列表)
// src/services/user-auth.ts — 认证逻辑(登录、登出、Token 管理)读操作和写操作的变化原因通常不同。查询条件经常因为前端需求调整(加筛选、改排序),而写操作的变化来自业务规则的演进(加校验、改流程)。把它们分开后,修改查询逻辑不会碰到写操作的代码,反之亦然。
延伸阅读
- 02-中大型项目结构 — 项目目录按职责拆分的全局视角
- 05-Routes层设计 — Routes 层与 Services 层的边界划分
- 07-Repositories层设计 — Repository 层如何承接 Service 层的数据访问需求
- 依赖注入容器 — Hono 生态中的 DI 方案
总结
Services 层在项目结构中扮演的角色是「业务逻辑的家」。它从 Routes 层接收已经校验过的参数,按业务规则编排 Repository 和外部服务的调用,再把结果返回给 Routes 层格式化成 HTTP 响应。
几个关键判断:
- 归属判断:把 HTTP 换掉后仍然有意义的逻辑,属于 Service 层
- 依赖方向:Service 依赖 Repository,不依赖 Routes 层;依赖从外部注入,不在内部直接获取
- 错误分类:业务错误用自定义异常类表达,系统错误向上传播由统一中间件处理
- 事务边界:跨 Repository 的一致性由 Service 层控制,Repository 只保证单条操作的原子性
- 拆分信号:方法数超过 15 个、涉及多个业务领域、依赖注入过多——这些都是拆分的信号
下一篇进入 Repositories 层设计——Repository 如何封装数据库访问、如何定义接口让 Service 层不直接依赖 SQL,以及 Drizzle ORM 在 Repository 层的集成方式。