返回博客

后端分层的边界意识:每一层只做自己该做的事

我第一次写 Node.js 后端的时候,所有逻辑堆在 API Route 里:查库、判断权限、拼装响应、发邮件通知,一个函数两三百行,读起来像流水账。那时候觉得「分层」是大公司才需要的仪式感——直到维护自己写的代码开始让我头疼,才明白分层不是规范问题,而是认知负荷问题。

三层各自的边界

Repository 层只做一件事:读写数据源。不做任何业务判断,不拼装响应格式,就连「查不到抛什么错」这种问题也由上层决定。Repository 的接口应该读起来像一句自然语言的陈述——findByIdfindAllByStatuscreate——而不是业务动词。

interface UserRepository {
  findById(id: string): Promise<User | null>
  findAllActive(): Promise<User[]>
  create(data: CreateUserDto): Promise<User>
}

这样做的收益在替换时最明显:把 PostgreSQL 换成 MySQL,或者把数据库查询换成外部 API 调用,上层代码不需要动一行。

Service 层是业务逻辑的唯一归宿。它调用 Repository 获取数据,执行判断、计算、状态流转,然后返回业务对象。Service 不应该知道「请求从哪来」,也不应该知道「响应该是什么格式」。一个判断标准:如果你能在 CLI 脚本里直接调用这个 Service 方法,它的边界大概率是对的。

Middleware / Handler 层处理横切关注点:认证、日志、限流、请求体解析。这些逻辑天然是管道形式——一个请求依次穿过若干个拦截器,每个拦截器要么放行,要么短路返回。把认证写成高阶函数包裹 Handler,比在每个 Handler 内部重复 if (!token) return 401 干净得多。

// 认证逻辑作为高阶函数,不侵入业务 Handler
export const GET = requireAuth(async (request, user) => {
  const data = await userService.getProfile(user.id)
  return NextResponse.json({ data })
})

缓存和错误处理放在哪

这两个问题很容易让分层模糊。

缓存最好通过「装饰器 Repository」注入:实现同一个 Repository 接口,内部先查缓存再穿透数据库。这样业务代码对「有没有缓存」完全无感知,缓存失效逻辑也集中在一处。

class CachedUserRepository implements UserRepository {
  constructor(private base: UserRepository, private cache: Cache) {}
 
  async findById(id: string): Promise<User | null> {
    const hit = await this.cache.get(`user:${id}`)
    if (hit) return JSON.parse(hit)
    const user = await this.base.findById(id)
    if (user) await this.cache.set(`user:${id}`, JSON.stringify(user), 300)
    return user
  }
}

错误处理的正确落点是 Middleware 层的统一 Handler,而不是每个 Service 方法自己拼响应。Service 抛业务异常(new ApiError(404, 'User not found')),验证层抛校验异常(Zod 的 ZodError),统一的错误 Middleware 捕获后决定 HTTP 状态码和响应结构。这让 Service 的单测干净很多——不需要断言响应格式,只需要断言抛出了正确的错误类型。

横向能力:限流与重试

限流属于 Middleware 层,作用在请求入口,与业务逻辑无关。重试则是个有意思的问题——重试应该在最靠近不可靠依赖的地方发生。如果是数据库偶发超时,在 Repository 层重试;如果是外部 API,在调用外部服务的 Client 层重试。Service 层见到的应该是「已经尝试过多次、最终失败」的语义错误,而不是原始的网络异常。

边界意识的核心

每一层只对「自己能完全掌控的信息」做决策。Repository 只知道数据模型,Service 只知道业务规则,Middleware 只知道请求/响应协议。越界的逻辑迟早会成为维护负担。

N+1 是边界失守的信号

N+1 查询问题几乎总是因为「在错误的地方循环调用 Repository」造成的。Service 层拿到一批记录,然后对每条记录再查一次关联数据——这说明 Service 直接在做本应由 Repository 聚合完成的事。修法很简单:Repository 提供批量接口,Service 一次拿到所有数据,再在内存里组装。

// Service 拿批量数据,内存组装,不循环调用 Repository
const users = await userRepo.findByIds(ids)           // 1 次查询
const map = new Map(users.map(u => [u.id, u]))
orders.forEach(o => { o.user = map.get(o.userId) })

一句话心法

分层不是目的,「每层可以独立被替换和测试」才是目的;当你发现某段逻辑很难写单测,往往是它跨越了应有的边界。