按业务模块拆分
要点
- 02 讲的按职责拆分(
routes/、middleware/、db/)是按技术层归类——同一类技术角色的代码放在一起 - 按业务模块拆分是另一种思路:把同一个业务领域相关的路由、服务、类型、校验、测试放进同一个目录
- 两种组织方式不互斥,中大型项目通常先用业务模块做大切分,模块内部再按技术职责分层
- 业务模块拆分的核心收益是「改一个功能时只需要打开一个目录」,不用在
routes/、services/、types/之间反复跳转 - 模块之间的依赖需要通过明确的导入路径暴露,不能绕过边界直接访问内部文件
- 共享代码(数据库连接、通用中间件、公共类型)放在模块外部的
shared/或项目根级别的公共目录
1. 从技术层拆分到业务模块拆分
02 里的目录结构按技术角色归类:所有路由放 routes/,所有中间件放 middleware/,所有数据库相关放 db/。项目有十几个接口时,这种结构还算清晰。但当接口增长到五六十个、路由文件膨胀到十几个之后,routes/ 目录本身会变成一个新的「什么都有」的文件夹。
也许你会先想到加子目录来缓解——routes/users/、routes/posts/、routes/billing/。这其实已经在往业务模块拆分的方向走了。区别在于:按技术层拆分时,routes/users.ts 只管路由,对应的 service 在 services/users.ts,类型在 types/users.ts,校验在 validators/users.ts,测试在 tests/users.test.ts。改一个用户相关的功能,要打开五六个不同目录下的文件。
按业务模块拆分的做法是把用户相关的所有东西放进一个 users/ 目录:
src/
├── modules/
│ ├── users/
│ │ ├── routes.ts # 用户路由
│ │ ├── service.ts # 用户业务逻辑
│ │ ├── schema.ts # 用户表结构定义
│ │ ├── types.ts # 用户相关类型
│ │ ├── validation.ts # 请求校验 schema
│ │ └── users.test.ts # 用户模块测试
│ ├── posts/
│ │ ├── routes.ts
│ │ ├── service.ts
│ │ ├── schema.ts
│ │ ├── types.ts
│ │ ├── validation.ts
│ │ └── posts.test.ts
│ └── auth/
│ ├── routes.ts
│ ├── service.ts
│ ├── types.ts
│ └── auth.test.ts
├── shared/
│ ├── middleware/
│ │ ├── auth.ts
│ │ └── logger.ts
│ ├── db/
│ │ ├── index.ts
│ │ └── drizzle.config.ts
│ └── types.ts # AppEnv 等全局类型
└── index.ts # 入口文件
同一个业务领域的代码集中在一个目录里。改用户功能,打开 modules/users/,需要的东西都在里面。
2. 一个完整模块的内部结构
以 users 模块为例,看每个文件各自承担什么职责。
// src/modules/users/types.ts
// 只暴露给外部使用的类型放这里,模块内部使用的类型不导出
export type User = {
id: number
email: string
name: string
role: 'admin' | 'member'
createdAt: string
}
// 给外部调用方看的精简视图
export type UserProfile = Pick<User, 'id' | 'name' | 'role'>// src/modules/users/schema.ts
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(),
role: text('role', { enum: ['admin', 'member'] }).notNull().default('member'),
createdAt: text('created_at').notNull(),
})// src/modules/users/validation.ts
import { z } from 'zod'
// 创建用户的请求体校验
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(50),
role: z.enum(['admin', 'member']).optional(),
})
// 更新用户的请求体校验
export const updateUserSchema = z.object({
name: z.string().min(1).max(50).optional(),
role: z.enum(['admin', 'member']).optional(),
})
// 路由参数校验
export const userIdParamSchema = z.object({
userId: z.string().regex(/^\d+$/).transform(Number),
})// src/modules/users/service.ts
import { eq } from 'drizzle-orm'
import type { DrizzleD1Database } from 'drizzle-orm/d1'
import { usersTable } from './schema'
import type { User } from './types'
type Db = DrizzleD1Database
// service 只依赖自己的 schema 和 types,不直接碰路由和 Context
export async function findAllUsers(db: Db): Promise<User[]> {
return db.select().from(usersTable).all()
}
export async function findUserById(db: Db, id: number): Promise<User | null> {
const rows = await db.select().from(usersTable).where(eq(usersTable.id, id)).all()
return rows[0] ?? null
}
export async function createUser(
db: Db,
input: { email: string; name: string; role?: 'admin' | 'member' }
): Promise<User> {
const result = await db
.insert(usersTable)
.values({
email: input.email,
name: input.name,
role: input.role ?? 'member',
createdAt: new Date().toISOString(),
})
.returning()
return result[0]
}// src/modules/users/routes.ts
import { Hono } from 'hono'
import { validator } from 'hono/validator'
import type { AppEnv } from '../../shared/types'
import { authMiddleware } from '../../shared/middleware/auth'
import { createUserSchema, updateUserSchema, userIdParamSchema } from './validation'
import * as userService from './service'
export const usersApp = new Hono<AppEnv>()
// 列表查询
usersApp.get('/', async (c) => {
const db = c.get('db')
const users = await userService.findAllUsers(db)
return c.json({ users })
})
// 单条查询——参数校验和处理逻辑分开了
usersApp.get(
'/:userId',
validator('param', (value, c) => {
const result = userIdParamSchema.safeParse(value)
if (!result.success) return c.json({ error: 'invalid userId' }, 400)
return result.data
}),
async (c) => {
const { userId } = c.req.valid('param')
const db = c.get('db')
const user = await userService.findUserById(db, userId)
if (!user) return c.json({ error: 'not found' }, 404)
return c.json({ user })
}
)
// 创建——需要鉴权
usersApp.post(
'/',
authMiddleware,
validator('json', (value, c) => {
const result = createUserSchema.safeParse(value)
if (!result.success) return c.json({ error: result.error.flatten() }, 400)
return result.data
}),
async (c) => {
const body = c.req.valid('json')
const db = c.get('db')
const user = await userService.createUser(db, body)
return c.json({ user }, 201)
}
)这个模块里每一层只做一件事:
types.ts定义数据结构schema.ts定义数据库表validation.ts定义请求校验规则service.ts封装业务逻辑,不碰 HTTP 概念routes.ts负责 HTTP 层的输入输出,调用 service
改校验规则只动 validation.ts,改数据库字段只动 schema.ts,改 HTTP 行为只动 routes.ts。
3. 入口文件:挂载模块
拆分之后的入口文件只有一件事——把每个业务模块的路由挂到对应的路径前缀下:
// src/index.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import type { AppEnv } from './shared/types'
import { usersApp } from './modules/users/routes'
import { postsApp } from './modules/posts/routes'
import { authApp } from './modules/auth/routes'
import { billingApp } from './modules/billing/routes'
const app = new Hono<AppEnv>()
// 全局中间件
app.use('*', logger())
app.use('*', cors())
// 挂载业务模块
app.route('/api/users', usersApp)
app.route('/api/posts', postsApp)
app.route('/api/auth', authApp)
app.route('/api/billing', billingApp)
app.get('/health', (c) => c.json({ status: 'ok' }))
export default app入口文件不知道每个模块内部怎么组织。它只知道两件事:这个模块的路由叫什么名字、挂到哪个路径前缀下。
4. 模块边界:什么留在里面,什么导出去
一个模块的文件分成两类:
内部文件:只被模块自己的其他文件引用。schema.ts 通常属于这类——其他模块不应该直接拿 users/schema.ts 里的 usersTable 去做查询,而应该通过 users/service.ts 暴露的函数来操作。
公开文件:被其他模块或入口文件引用。routes.ts、types.ts 通常属于这类。
可以用一个 index.ts 显式声明模块的公开 API:
// src/modules/users/index.ts
// 模块的公开入口——外部只需要 import 这个文件
export { usersApp } from './routes'
export type { User, UserProfile } from './types'
// 如果其他模块需要调用用户相关的业务逻辑(比如 posts 模块需要在创建文章时查用户)
// 从这里导出 service 函数,而不是让外部直接 import ./service
export { findUserById } from './service'其他模块需要用到用户相关的东西时,统一从 modules/users/index.ts 导入:
// src/modules/posts/service.ts
import { findUserById } from '../users'
// 不是 import { findUserById } from '../users/service'这样做的好处是:如果将来调整了模块内部的文件结构(比如把 service.ts 拆成 query.ts 和 command.ts),外部模块的导入路径不需要改。
5. 跨模块依赖怎么处理
业务模块之间不可能完全独立。一篇「文章」属于某个「用户」,创建文章时要查用户信息。这种跨模块的依赖有两种处理方式。
方式一:通过公开接口调用另一个模块的 service
// src/modules/posts/service.ts
import { findUserById } from '../users'
export async function createPost(
db: Db,
input: { title: string; content: string; authorId: number }
) {
// 跨模块调用——通过 users 模块的公开接口查用户
const author = await findUserById(db, input.authorId)
if (!author) throw new Error('author not found')
// ...创建文章的逻辑
}这种方式简单直接,适合模块之间的依赖关系比较少、比较浅的场景。
方式二:把共享逻辑下沉到 shared/
如果多个模块都需要做的操作——比如「根据 ID 查一个实体是否存在」——在各自模块里重复实现就不合适了。把它提取到 shared/ 目录下:
shared/
├── services/
│ └── entity-exists.ts # 通用的「按 ID 查实体是否存在」
├── middleware/
│ └── auth.ts
├── db/
│ └── index.ts
└── types.ts
判断标准是:如果一段逻辑被三个以上的模块引用,或者它本身不属于任何一个特定业务领域,就放到 shared/ 里。
6. 共享代码的归属
shared/ 目录放的是真正跨模块的公共代码。几个常见的归属判断:
| 代码 | 归属 | 原因 |
|---|---|---|
| 数据库连接实例 | shared/db/ | 所有模块共用同一个数据库连接 |
AppEnv 类型 | shared/types.ts | 全局类型定义 |
| 鉴权中间件 | shared/middleware/ | 多个模块的路由都需要用 |
| 统一错误处理 | shared/errors/ | 跨模块的错误格式 |
usersTable 定义 | modules/users/schema.ts | 只属于用户模块 |
| 用户创建逻辑 | modules/users/service.ts | 只属于用户模块 |
有一个容易犯的错误是把「看起来通用但实际上只被一个模块用」的代码过早放到 shared/ 里。先放在模块内部,等到第二个模块确实需要引用时再提取出去,通常更稳妥。
7. 什么时候适合按业务模块拆分
按业务模块拆分有几个前提条件:
- 业务领域比较清晰。能说出「用户」「文章」「认证」「计费」这些模块的名字,说明业务本身有自然的分界
- 项目有一定规模。如果只有三四个路由文件,按技术层拆分就够了,加模块目录反而多一层嵌套
- 团队有分工或计划有分工。一个人负责用户模块,另一个人负责文章模块,各自改各自的目录,减少冲突
如果项目是一个内部管理后台,只有一个「管理员」角色操作所有功能,业务领域本身就比较模糊,按技术层拆分可能更直接。
反过来,如果是面向多角色的 SaaS 产品——有租户管理、用户权限、订阅计费、内容管理等明确的业务域——按模块拆分之后,每个域的代码独立演进,改计费逻辑不会影响用户模块的文件。
8. 从技术层拆分迁移到业务模块拆分
如果项目已经在用 02 的结构(routes/、services/、types/ 各自独立),迁移到模块结构可以按模块逐个进行,不需要一次性全部改完。
以用户模块为例,迁移步骤:
- 创建
src/modules/users/目录 - 把
routes/users.ts移到modules/users/routes.ts - 把
services/users.ts移到modules/users/service.ts - 把
types/users.ts移到modules/users/types.ts - 把
validators/users.ts移到modules/users/validation.ts - 把
tests/users.test.ts移到modules/users/users.test.ts - 更新所有 import 路径
- 在
modules/users/index.ts里导出公开 API - 更新
src/index.ts的导入路径
迁移前: 迁移后:
routes/users.ts → modules/users/routes.ts
services/users.ts → modules/users/service.ts
types/users.ts → modules/users/types.ts
validators/users.ts → modules/users/validation.ts
tests/users.test.ts → modules/users/users.test.ts
每次只迁一个模块,迁移完跑一遍 pnpm typecheck 和 pnpm test,确认没有问题再迁下一个。老的 routes/、services/ 等目录在全部迁完之后自然清空,再删除空目录。
9. 两种拆分方式的对比
把技术层拆分和业务模块拆分放在一起看:
按技术层拆分(02 的结构):
- 目录结构:
routes/、services/、middleware/、db/、types/ - 适合场景:项目早期,功能少,业务域不清晰
- 优点:结构简单,同类代码集中,新人一眼就能看到「项目有哪些路由」
- 缺点:功能增长后,找某个功能的完整代码要在多个目录之间跳转
按业务模块拆分:
- 目录结构:
modules/users/、modules/posts/、modules/auth/ - 适合场景:中大型项目,业务域清晰,多人协作
- 优点:改一个功能只需要打开一个目录,模块之间互不干扰
- 缺点:多了一层嵌套,小项目里显得过度组织
两种方式可以混合使用。模块内部按技术职责分层(routes.ts、service.ts、schema.ts),模块之间按业务域划分——这是中大型 Hono 项目比较常见的组织方式。
延伸阅读
- 02-中大型项目结构 — 按技术层拆分的基础结构,本篇的对比参照
- 04.02-动态路由参数 — 路由模块中参数校验的完整用法
- Hono Sub App 文档 —
app.route()的子应用挂载机制 - Bulletproof Node.js Project Architecture — 按业务领域组织 Node.js 项目的经典参考
总结
按业务模块拆分的核心思路:
- 一个业务域一个目录。
modules/users/里包含用户相关的路由、service、schema、类型、校验和测试 - 模块内部按技术职责分层。
routes.ts管 HTTP 层,service.ts管业务逻辑,schema.ts管数据库表结构 - 用
index.ts声明模块的公开 API。外部模块只从入口文件导入,不直接引用内部文件 - 共享代码放
shared/。只有真正跨模块复用的东西才下沉到公共目录 - 可以逐个模块迁移。不需要一次性重构整个项目,迁一个验一个
这种组织方式在业务域清晰的项目中效果明显。下一篇会讲另一种拆分思路——按技术层拆分,看看它在哪些场景下反而比业务模块拆分更合适。