模块边界与依赖规则

要点

  • 模块边界是代码之间的一道「门」——外部只能通过显式的 import 访问模块导出的内容,不能直接穿透到模块内部文件
  • 依赖方向必须单向流动:Routes → Services → Repositories → Database,上层可以调用下层,下层不能反向依赖上层
  • 循环依赖会让模块无法独立理解和独立测试,改一个地方会波及整个依赖环
  • 禁止的依赖模式包括:Repository 导入 Service、下层直接导入上层的路由处理函数、同层模块之间无约束地互相调用
  • 检测循环依赖可以靠构建工具(如 madge)、CI 阶段的 lint 规则、或 TypeScript 的 import 约束
  • 模块边界不需要一步到位,可以从「先禁止 Repository 直接访问 HTTP 上下文」这类具体约束开始,逐步收紧

1. 从一个常见问题说起

03 按业务模块拆分之后,项目结构从按技术角色归类(routes/services/db/)变成了按业务域聚合(modules/users/modules/posts/)。目录拆开了,但 import 没有限制。

你可能会遇到这种情况:users 模块的 Repository 直接 import 了 posts 模块的 Service,posts 模块的 Service 又反过来 import users 模块的 Repository。代码能跑,但改 users 的数据库查询时,你不确定会不会影响到 posts 的业务逻辑。

再具体一点,看看这段代码:

// src/modules/users/repository.ts
import { validatePostOwnership } from '../posts/service'
 
export async function getUserWithPosts(userId: number, db: Database) {
  // Repository 里直接调用了另一个模块的 Service
  const user = await db.select().from(usersTable).where(eq(usersTable.id, userId))
  const isValid = await validatePostOwnership(userId, db)
  return { user, isValid }
}
// src/modules/posts/service.ts
import { getUserPreferences } from '../users/repository'
 
export async function validatePostOwnership(userId: number, db: Database) {
  // Service 里又依赖了另一个模块的 Repository
  const prefs = await getUserPreferences(userId, db)
  if (!prefs.allowPostCreation) return false
  // ...
  return true
}

两个模块互相 import,形成了一个环:users/repositoryposts/serviceusers/repository。现在想单独改 users 模块,你必须同时理解 posts 模块的逻辑。想单独测试 users 模块,你必须把 posts 也拉进来。

这个问题不是代码写得「不好」,而是缺少约束——模块之间没有规定谁可以依赖谁。

2. 什么是模块边界

模块边界是指一个模块对外暴露的接口和内部实现之间的分界线。在 JavaScript / TypeScript 项目里,模块边界的体现方式很直接:

  1. 模块通过 export 声明哪些内容可以被外部访问
  2. 外部只能通过 import 获取这些导出的内容
  3. 模块内部没有 export 的文件或函数,外部不能直接引用

用一个具体例子来说明。假设 users 模块的目录结构是这样的:

src/modules/users/
├── index.ts          # 对外导出入口
├── routes.ts         # 路由定义
├── service.ts        # 业务逻辑
├── repository.ts     # 数据访问
├── types.ts          # 模块内部类型
└── utils.ts          # 模块内部工具函数

模块边界由 index.ts 控制:

// src/modules/users/index.ts
// 只导出外部需要的内容
export { usersApp } from './routes'
export type { User, CreateUserInput } from './types'
export { userService } from './service'
 
// routes.ts、repository.ts、utils.ts 没有导出
// 外部不应该直接 import 它们

其他模块访问 users 时,统一走 index.ts 这一个入口:

// src/modules/posts/service.ts
// ✅ 通过模块入口导入
import { userService } from '../users'
 
// ❌ 绕过边界,直接访问内部文件
import { userService } from '../users/service'

也许你会觉得这只是「约定」,TypeScript 又拦不住直接 import ../users/service。确实如此——但在项目规模小的时候靠约定够用,等到模块数量超过十个,没有边界的 import 会让依赖关系变成一团网状结构,改一处要追十几处。

3. 为什么需要依赖规则

模块边界解决了「谁能看到谁」的问题。但光有边界还不够——你还需要规定依赖的方向。

先看一个没有依赖规则的项目在半年后会变成什么样:

  1. routes/users.ts 直接写 SQL 查询(跳过了 Service 层)
  2. db/schema.ts 里 import 了路由处理函数,因为「当时觉得方便」
  3. middleware/auth.ts 里直接操作数据库,因为「不想再走一遍 Service」
  4. 两个 Service 互相调用,形成了环形依赖

这些不是假想的场景,是没有依赖规则的项目在迭代半年后几乎一定会出现的状态。根本原因在于:每一次「图方便」的 import 都在增加模块之间的耦合度。耦合度到了一定阈值,改任何一行代码都可能产生意想不到的副作用。

依赖规则做的事情很简单——把允许和禁止的依赖方向写清楚,然后在代码审查和 CI 里执行

4. 依赖方向:上层依赖下层

一个分层清晰的 Hono 项目,依赖方向遵循一个基本原则:上层依赖下层,下层不依赖上层

先把项目分成四层,从外到内分别是:

┌─────────────────────────────────┐
│  Routes(路由层)                 │  ← 最外层,接收 HTTP 请求
├─────────────────────────────────┤
│  Services(业务逻辑层)           │  ← 处理业务规则
├─────────────────────────────────┤
│  Repositories(数据访问层)       │  ← 执行数据库操作
├─────────────────────────────────┤
│  Database / External(基础设施层) │  ← 数据库连接、外部 API
└─────────────────────────────────┘

依赖方向用箭头表示:

Routes → Services → Repositories → Database

每一层只能 import 它下面那一层的导出。具体规则:

  1. Routes 可以 import Services 和 Repositories,但推荐只 import Services,由 Services 再去调用 Repositories
  2. Services 可以 import Repositories,调用数据访问方法
  3. Repositories 只能 import Database 相关的工具(Drizzle 实例、schema 定义)
  4. Database 层不能 import 任何业务代码

把规则翻译成代码:

// src/routes/users.ts
// ✅ Routes → Services
import { userService } from '../services/users'
 
export const usersApp = new Hono<AppEnv>()
 
usersApp.get('/:id', async (c) => {
  const id = Number(c.req.param('id'))
  const user = await userService.findById(id)
  return c.json(user)
})
// src/services/users.ts
// ✅ Services → Repositories
import { userRepository } from '../repositories/users'
 
export const userService = {
  async findById(id: number) {
    return userRepository.findById(id)
  },
 
  async createUser(input: CreateUserInput) {
    // 业务规则:检查邮箱是否重复
    const existing = await userRepository.findByEmail(input.email)
    if (existing) throw new EmailAlreadyExistsError()
    return userRepository.create(input)
  },
}
// src/repositories/users.ts
// ✅ Repositories → Database
import { db } from '../db'
import { usersTable } from '../db/schema'
 
export const userRepository = {
  async findById(id: number) {
    return db.select().from(usersTable).where(eq(usersTable.id, id)).get()
  },
 
  async findByEmail(email: string) {
    return db.select().from(usersTable).where(eq(usersTable.email, email)).get()
  },
 
  async create(input: CreateUserInput) {
    return db.insert(usersTable).values(input).returning().get()
  },
}
// src/db/schema.ts
// ✅ Database 层:不 import 任何业务代码
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
 
export const usersTable = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
})

判断一段代码的依赖方向是否合规,可以用一个简单的测试:把上层的代码全删掉,下层的代码应该还能正常编译。删掉 Routes 层,Services 和 Repositories 不应该报错。删掉 Services 层,Repositories 不应该报错。如果反过来——删掉 Routes 之后 Repository 报错了,说明 Repository 依赖了 Routes,方向反了。

5. 禁止的依赖模式

知道了正确的方向,再把常见的违规模式列出来,方便对照排查。

Repository 导入 Service

// src/repositories/posts.ts
// ❌ Repository 不能依赖 Service
import { notificationService } from '../services/notifications'
 
export const postRepository = {
  async create(data: CreatePostInput) {
    const post = await db.insert(postsTable).values(data).returning().get()
    // 在 Repository 里调用 Service,职责混了
    await notificationService.notifyNewPost(post)
    return post
  },
}

Repository 的职责是把数据写进数据库或从数据库读出来。通知用户、校验业务规则这些事情属于 Service 层。把通知逻辑放在 Repository 里,等于让数据访问层承担了业务流程编排的职责。

正确的做法是在 Service 层编排:

// src/services/posts.ts
// ✅ Service 层负责编排多个步骤
import { postRepository } from '../repositories/posts'
import { notificationService } from '../services/notifications'
 
export const postService = {
  async createPost(data: CreatePostInput) {
    const post = await postRepository.create(data)
    await notificationService.notifyNewPost(post)
    return post
  },
}

下层直接导入上层的路由

// src/services/users.ts
// ❌ Service 不能依赖 Routes
import { authMiddleware } from '../routes/auth'
 
export const userService = {
  // 在 Service 里引用路由中间件,层级关系反了
}

Routes 层处理的是 HTTP 协议相关的事情(请求解析、响应格式化、状态码)。Service 层处理的是业务规则。如果 Service 需要知道用户是否已登录,它应该接收一个 userId 参数,而不是去 import 鉴权中间件。

同层模块无约束地互相调用

// src/services/users.ts
import { orderService } from './orders'
 
// src/services/orders.ts
import { userService } from './users'
 
// ❌ 两个 Service 互相 import,形成循环

这种情况在功能上可能暂时没问题,但它意味着 usersorders 已经无法独立理解、独立测试、独立部署。后面会专门讨论循环依赖的危害和检测方法。

汇总

依赖方向是否允许说明
Routes → Services✅ 允许路由调用 Service 处理业务
Routes → Repositories⚠️ 谨慎简单查询可以跳过 Service,但业务逻辑不应该绕过
Services → Repositories✅ 允许Service 通过 Repository 访问数据
Services → Services⚠️ 谨慎单向可以,双向会形成循环
Repositories → Services❌ 禁止下层不能依赖上层
Repositories → Routes❌ 禁止下层不能依赖上层
Database → 任何业务代码❌ 禁止基础设施层不能依赖业务层

6. 循环依赖的危害

循环依赖是指两个或多个模块通过 import 形成了环形引用链。前面第 1 节的例子就是一个循环:users/repositoryposts/serviceusers/repository

循环依赖的问题不是「代码看起来乱」,而是它会在三个层面造成具体的技术后果。

编译和加载顺序不确定

JavaScript 的模块系统在处理循环 import 时,会返回一个未完成初始化的模块对象。在 CommonJS 里,你可能见过 undefined is not a function 这类错误——原因就是模块 A 导入模块 B 时,B 还没执行完,导出的函数还是 undefined

ES Module 的处理稍好一些,它对声明式导出(export functionexport const)有提升处理,但如果是 export default 一个对象字面量,在循环依赖场景下依然可能拿到 undefined

无法独立测试

假设你想测试 users/repositorygetUserWithPosts 方法。因为它 import 了 posts/service,而 posts/service 又 import 了 users/repository,你的测试必须把整个环都拉进来才能运行。mock 其中一个模块,另一个模块的初始化行为就可能改变。

没有循环依赖时,测试 users/repository 只需要 mock Database 层。测试范围是可控的。

改动的波及面不可控

循环依赖意味着两个模块的生命周期绑定了。改 users 的 Repository,需要同时回归测试 posts 的 Service——因为 posts/service 的初始化依赖 users/repository 的导出。随着循环链越来越长(A → B → C → A),改任何一环都要回归整个环上的所有模块。

7. 如何检测循环依赖

使用 madge

madge 是一个静态分析工具,可以扫描项目中的 import 关系并检测循环依赖:

// terminal
# 安装
pnpm add -D madge
 
# 扫描并输出循环依赖
pnpm madge --circular src/

输出类似:

✖ Found 2 circular dependencies:

1) src/modules/users/repository.ts → src/modules/posts/service.ts → src/modules/users/repository.ts
2) src/services/orders.ts → src/services/users.ts → src/services/orders.ts

可以把这条命令加到 CI 流程里,出现新的循环依赖时直接让构建失败:

// package.json
{
  "scripts": {
    "check:circular": "madge --circular src/",
    "verify": "pnpm typecheck && pnpm lint && pnpm check:circular && pnpm test"
  }
}

使用 ESLint import 规则

如果项目用 ESLint,可以启用 eslint-plugin-importno-cycle 规则:

// .eslintrc.json
{
  "rules": {
    "import/no-cycle": "error"
  }
}

这条规则会在编辑代码时就标出循环引用,不用等到 CI 才发现。

使用 TypeScript 路径别名

通过 tsconfig.jsonpaths 配置把模块访问路径按层归类(@services/*@repositories/*@db/*)。路径别名本身不强制依赖方向,但配合代码审查时,看到 @repositories/ 里出现了 @services/ 的 import 就能立刻识别出问题。

用 barrel 文件(index.ts)收敛入口

前面第 2 节提到的 index.ts 导出入口,除了让模块接口更清晰之外,还有一个附带效果:外部模块只能通过 barrel 文件访问,无法穿透到内部文件。虽然 TypeScript 不能阻止直接 import 内部路径,但在代码审查时,import ... from '../users'import ... from '../users/repository' 的可见性完全不同——前者说明是通过正式入口访问,后者说明绕过了边界。

8. 模块边界的渐进式实施

前面的规则列了不少。如果你的项目现在已经是一团互相 import 的状态,不太可能一天之内全部修正。更实际的做法是分阶段收紧约束。

第一阶段:先断开最明显的反向依赖

最优先处理的是「下层直接 import 上层」的情况。这类依赖违反了最基本的分层原则,修复成本通常也不高:

  1. Repository 里如果有调用 Service 的代码,把调用提到 Service 层
  2. Service 里如果有引用 Routes 的代码(比如 HTTP 上下文、请求参数类型),把参数改成纯数据类型
  3. Schema 文件里如果有 import 业务代码,把业务逻辑挪走

这些修完之后,依赖方向至少是「大部分正确的」,剩下的问题多半是同层之间的循环依赖。

第二阶段:打断循环依赖

循环依赖的修复方式通常是在环中间插入一个协调者。以 users/repositoryposts/serviceusers/repository 为例,修复思路是:

  1. 找到环里职责最高的那一层——posts/service 是业务编排,比 Repository 更适合做协调
  2. users/repository 里依赖 posts/service 的逻辑抽出来,放到一个更高层的 Service 里
  3. 让新的 Service 同时依赖 users/repositoryposts/service,环就拆成了两条单向链
// src/services/user-posts.ts
// ✅ 新增的 Service 作为协调者,打断循环
import { userRepository } from '../repositories/users'
import { postService } from './posts'
 
export const userPostService = {
  async getUserWithPosts(userId: number) {
    const user = await userRepository.findById(userId)
    const isValid = await postService.validateOwnership(userId)
    return { user, isValid }
  },
}

第三阶段:收敛 import 入口

当前两个阶段的修正稳定之后,可以给每个模块加上 index.ts barrel 文件,把对外导出收敛到一个入口。然后在代码审查时逐步推行「只通过 barrel 导入」的约定。

这一步不需要工具强制——当依赖方向已经正确、循环依赖已经拆开之后,barrel 文件更多是组织层面的改善,让 import 路径更短、模块接口更明确。

第四阶段:CI 自动化

madge --circular 或 ESLint import/no-cycle 加到 CI 流程里。一旦构建管线能自动检测循环依赖,就不需要每次代码审查时人工检查了。

到这个阶段,模块边界从「团队约定」变成了「工具约束」。新人加入项目时,不需要先读完所有的边界规则——CI 会在他写了违规 import 时直接报错。

总结

回顾一下这篇的要点:

  • 模块边界是模块对外暴露接口和内部实现之间的分界线,在 TypeScript 项目里通过 exportindex.ts barrel 文件体现
  • 依赖方向必须单向:Routes → Services → Repositories → Database。上层调用下层,下层不能反向依赖上层
  • 禁止的模式:Repository 导入 Service、Service 引用 Routes、下层直接访问上层的内部文件
  • 循环依赖会让编译顺序不确定、测试无法隔离、改动波及面失控
  • 检测手段包括 madge、ESLint import/no-cycle、CI 管线自动检查
  • 实施路径分四步走:先断开反向依赖,再打断循环依赖,然后收敛 import 入口,最后用 CI 固化规则

下一篇会把这些规则和前面几篇的 Services、Repositories 层设计结合起来,看一个完整的实战项目是如何组织的。