按业务模块拆分

要点

  • 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)
  }
)

这个模块里每一层只做一件事:

  1. types.ts 定义数据结构
  2. schema.ts 定义数据库表
  3. validation.ts 定义请求校验规则
  4. service.ts 封装业务逻辑,不碰 HTTP 概念
  5. 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.tstypes.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.tscommand.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. 什么时候适合按业务模块拆分

按业务模块拆分有几个前提条件:

  1. 业务领域比较清晰。能说出「用户」「文章」「认证」「计费」这些模块的名字,说明业务本身有自然的分界
  2. 项目有一定规模。如果只有三四个路由文件,按技术层拆分就够了,加模块目录反而多一层嵌套
  3. 团队有分工或计划有分工。一个人负责用户模块,另一个人负责文章模块,各自改各自的目录,减少冲突

如果项目是一个内部管理后台,只有一个「管理员」角色操作所有功能,业务领域本身就比较模糊,按技术层拆分可能更直接。

反过来,如果是面向多角色的 SaaS 产品——有租户管理、用户权限、订阅计费、内容管理等明确的业务域——按模块拆分之后,每个域的代码独立演进,改计费逻辑不会影响用户模块的文件。

8. 从技术层拆分迁移到业务模块拆分

如果项目已经在用 02 的结构(routes/services/types/ 各自独立),迁移到模块结构可以按模块逐个进行,不需要一次性全部改完。

以用户模块为例,迁移步骤:

  1. 创建 src/modules/users/ 目录
  2. routes/users.ts 移到 modules/users/routes.ts
  3. services/users.ts 移到 modules/users/service.ts
  4. types/users.ts 移到 modules/users/types.ts
  5. validators/users.ts 移到 modules/users/validation.ts
  6. tests/users.test.ts 移到 modules/users/users.test.ts
  7. 更新所有 import 路径
  8. modules/users/index.ts 里导出公开 API
  9. 更新 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 typecheckpnpm test,确认没有问题再迁下一个。老的 routes/services/ 等目录在全部迁完之后自然清空,再删除空目录。

9. 两种拆分方式的对比

把技术层拆分和业务模块拆分放在一起看:

按技术层拆分(02 的结构):

  • 目录结构:routes/services/middleware/db/types/
  • 适合场景:项目早期,功能少,业务域不清晰
  • 优点:结构简单,同类代码集中,新人一眼就能看到「项目有哪些路由」
  • 缺点:功能增长后,找某个功能的完整代码要在多个目录之间跳转

按业务模块拆分

  • 目录结构:modules/users/modules/posts/modules/auth/
  • 适合场景:中大型项目,业务域清晰,多人协作
  • 优点:改一个功能只需要打开一个目录,模块之间互不干扰
  • 缺点:多了一层嵌套,小项目里显得过度组织

两种方式可以混合使用。模块内部按技术职责分层(routes.tsservice.tsschema.ts),模块之间按业务域划分——这是中大型 Hono 项目比较常见的组织方式。

延伸阅读

总结

按业务模块拆分的核心思路:

  1. 一个业务域一个目录modules/users/ 里包含用户相关的路由、service、schema、类型、校验和测试
  2. 模块内部按技术职责分层routes.ts 管 HTTP 层,service.ts 管业务逻辑,schema.ts 管数据库表结构
  3. index.ts 声明模块的公开 API。外部模块只从入口文件导入,不直接引用内部文件
  4. 共享代码放 shared/。只有真正跨模块复用的东西才下沉到公共目录
  5. 可以逐个模块迁移。不需要一次性重构整个项目,迁一个验一个

这种组织方式在业务域清晰的项目中效果明显。下一篇会讲另一种拆分思路——按技术层拆分,看看它在哪些场景下反而比业务模块拆分更合适。