模块边界与依赖规则
要点
- 模块边界是代码之间的一道「门」——外部只能通过显式的 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/repository → posts/service → users/repository。现在想单独改 users 模块,你必须同时理解 posts 模块的逻辑。想单独测试 users 模块,你必须把 posts 也拉进来。
这个问题不是代码写得「不好」,而是缺少约束——模块之间没有规定谁可以依赖谁。
2. 什么是模块边界
模块边界是指一个模块对外暴露的接口和内部实现之间的分界线。在 JavaScript / TypeScript 项目里,模块边界的体现方式很直接:
- 模块通过
export声明哪些内容可以被外部访问 - 外部只能通过
import获取这些导出的内容 - 模块内部没有
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. 为什么需要依赖规则
模块边界解决了「谁能看到谁」的问题。但光有边界还不够——你还需要规定依赖的方向。
先看一个没有依赖规则的项目在半年后会变成什么样:
routes/users.ts直接写 SQL 查询(跳过了 Service 层)db/schema.ts里 import 了路由处理函数,因为「当时觉得方便」middleware/auth.ts里直接操作数据库,因为「不想再走一遍 Service」- 两个 Service 互相调用,形成了环形依赖
这些不是假想的场景,是没有依赖规则的项目在迭代半年后几乎一定会出现的状态。根本原因在于:每一次「图方便」的 import 都在增加模块之间的耦合度。耦合度到了一定阈值,改任何一行代码都可能产生意想不到的副作用。
依赖规则做的事情很简单——把允许和禁止的依赖方向写清楚,然后在代码审查和 CI 里执行。
4. 依赖方向:上层依赖下层
一个分层清晰的 Hono 项目,依赖方向遵循一个基本原则:上层依赖下层,下层不依赖上层。
先把项目分成四层,从外到内分别是:
┌─────────────────────────────────┐
│ Routes(路由层) │ ← 最外层,接收 HTTP 请求
├─────────────────────────────────┤
│ Services(业务逻辑层) │ ← 处理业务规则
├─────────────────────────────────┤
│ Repositories(数据访问层) │ ← 执行数据库操作
├─────────────────────────────────┤
│ Database / External(基础设施层) │ ← 数据库连接、外部 API
└─────────────────────────────────┘
依赖方向用箭头表示:
Routes → Services → Repositories → Database
每一层只能 import 它下面那一层的导出。具体规则:
- Routes 可以 import Services 和 Repositories,但推荐只 import Services,由 Services 再去调用 Repositories
- Services 可以 import Repositories,调用数据访问方法
- Repositories 只能 import Database 相关的工具(Drizzle 实例、schema 定义)
- 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,形成循环这种情况在功能上可能暂时没问题,但它意味着 users 和 orders 已经无法独立理解、独立测试、独立部署。后面会专门讨论循环依赖的危害和检测方法。
汇总
| 依赖方向 | 是否允许 | 说明 |
|---|---|---|
| Routes → Services | ✅ 允许 | 路由调用 Service 处理业务 |
| Routes → Repositories | ⚠️ 谨慎 | 简单查询可以跳过 Service,但业务逻辑不应该绕过 |
| Services → Repositories | ✅ 允许 | Service 通过 Repository 访问数据 |
| Services → Services | ⚠️ 谨慎 | 单向可以,双向会形成循环 |
| Repositories → Services | ❌ 禁止 | 下层不能依赖上层 |
| Repositories → Routes | ❌ 禁止 | 下层不能依赖上层 |
| Database → 任何业务代码 | ❌ 禁止 | 基础设施层不能依赖业务层 |
6. 循环依赖的危害
循环依赖是指两个或多个模块通过 import 形成了环形引用链。前面第 1 节的例子就是一个循环:users/repository → posts/service → users/repository。
循环依赖的问题不是「代码看起来乱」,而是它会在三个层面造成具体的技术后果。
编译和加载顺序不确定
JavaScript 的模块系统在处理循环 import 时,会返回一个未完成初始化的模块对象。在 CommonJS 里,你可能见过 undefined is not a function 这类错误——原因就是模块 A 导入模块 B 时,B 还没执行完,导出的函数还是 undefined。
ES Module 的处理稍好一些,它对声明式导出(export function、export const)有提升处理,但如果是 export default 一个对象字面量,在循环依赖场景下依然可能拿到 undefined。
无法独立测试
假设你想测试 users/repository 的 getUserWithPosts 方法。因为它 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-import 的 no-cycle 规则:
// .eslintrc.json
{
"rules": {
"import/no-cycle": "error"
}
}这条规则会在编辑代码时就标出循环引用,不用等到 CI 才发现。
使用 TypeScript 路径别名
通过 tsconfig.json 的 paths 配置把模块访问路径按层归类(@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 上层」的情况。这类依赖违反了最基本的分层原则,修复成本通常也不高:
- Repository 里如果有调用 Service 的代码,把调用提到 Service 层
- Service 里如果有引用 Routes 的代码(比如 HTTP 上下文、请求参数类型),把参数改成纯数据类型
- Schema 文件里如果有 import 业务代码,把业务逻辑挪走
这些修完之后,依赖方向至少是「大部分正确的」,剩下的问题多半是同层之间的循环依赖。
第二阶段:打断循环依赖
循环依赖的修复方式通常是在环中间插入一个协调者。以 users/repository → posts/service → users/repository 为例,修复思路是:
- 找到环里职责最高的那一层——
posts/service是业务编排,比 Repository 更适合做协调 - 把
users/repository里依赖posts/service的逻辑抽出来,放到一个更高层的 Service 里 - 让新的 Service 同时依赖
users/repository和posts/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 项目里通过
export和index.tsbarrel 文件体现 - 依赖方向必须单向:Routes → Services → Repositories → Database。上层调用下层,下层不能反向依赖上层
- 禁止的模式:Repository 导入 Service、Service 引用 Routes、下层直接访问上层的内部文件
- 循环依赖会让编译顺序不确定、测试无法隔离、改动波及面失控
- 检测手段包括
madge、ESLintimport/no-cycle、CI 管线自动检查 - 实施路径分四步走:先断开反向依赖,再打断循环依赖,然后收敛 import 入口,最后用 CI 固化规则
下一篇会把这些规则和前面几篇的 Services、Repositories 层设计结合起来,看一个完整的实战项目是如何组织的。