按技术层拆分
要点
- 按技术层拆分是把代码按职责分组——路由放
routes/,业务逻辑放services/,数据访问放repositories/ - 这种结构来自经典的分层架构模式:表现层 → 业务层 → 数据层
- 每一层只能调用下一层,不能反向依赖——
routes → services → repositories - 层与层之间通过类型和接口约定通信契约,而不是直接穿透
- 技术层拆分适合技术边界清晰、业务复杂度中等的项目
- 当项目继续增长时,同一层里的文件数量会膨胀,需要考虑混合拆分策略
- 和按业务模块拆分相比,技术层拆分更容易找到「某类代码在哪」,但更难看清「某个功能涉及哪些文件」
1. 什么是技术层拆分
02 里我们按职责把代码拆成了 routes/、middleware/、db/、lib/ 几个目录。这其实已经是技术层拆分的雏形——路由归路由,数据库归数据库,中间件归中间件。
但 02 的结构还比较扁平。随着功能增多,你会发现 routes/ 下的文件越来越多,数据库操作散落在各个路由处理函数里,类型定义、工具函数、错误处理也没有明确的归属。
技术层拆分(Technical Layer Splitting)的思路是:按代码的技术职责把项目分成若干层,每一层只做一类事情。
一个典型的分层目录结构:
src/
├── routes/ # 路由层:接收请求,返回响应
│ ├── users.ts
│ ├── posts.ts
│ └── auth.ts
├── services/ # 业务层:业务逻辑、编排
│ ├── user-service.ts
│ ├── post-service.ts
│ └── auth-service.ts
├── repositories/ # 数据层:数据库读写
│ ├── user-repo.ts
│ └── post-repo.ts
├── schemas/ # 数据校验 schema
│ ├── user.ts
│ └── post.ts
├── types/ # 类型定义
│ ├── env.ts
│ └── entities.ts
├── middleware/ # 中间件
│ ├── auth.ts
│ └── logger.ts
└── index.ts # 入口文件
每一层的职责是单一的:routes/ 只管 HTTP 协议相关的事(解析参数、组装响应),services/ 只管业务规则(用户能否删除这篇帖子、订单金额怎么算),repositories/ 只管数据存取(SQL 怎么写、查询怎么拼)。
2. 分层架构模式
技术层拆分不是 Hono 或某个框架的发明,它来自一个历史更久的软件架构模式——分层架构(Layered Architecture)。
经典的三层模型把系统分成:
- 表现层(Presentation):负责和用户交互——接收输入、格式化输出。在 Web API 里就是路由和控制器
- 业务层(Business / Domain):负责业务规则和逻辑——计算、校验、状态转换
- 数据层(Data Access):负责数据存储——读写数据库、调用外部存储
映射到 Hono 项目里:
| 分层 | 对应目录 | 职责 |
|---|---|---|
| 表现层 | routes/ | 接收 HTTP 请求,调用 service,返回 JSON 响应 |
| 业务层 | services/ | 实现业务规则,编排多个 repository 的操作 |
| 数据层 | repositories/ | 封装数据库查询,提供 CRUD 接口 |
中间件(middleware/)、类型定义(types/)、schema(schemas/)这些属于横切关注点,它们不严格属于某一层,但作为基础设施存在。
3. 依赖规则:只能向下调用
分层架构有一条核心约束:上层可以调用下层,下层不能调用上层。
routes → services → repositories
↑ ↑ ↑
可以调用下层 可以调用下层 不能再往下调
具体来说:
routes/可以调用services/和repositories/services/可以调用repositories/repositories/不能调用services/或routes/
这条规则听起来简单,但在实际编码中很容易违反。比如:
// src/repositories/user-repo.ts
// ❌ 错误:repository 层反向依赖了 service 层
import { hashPassword } from '../services/auth-service'
export async function createUser(db: D1Database, email: string, rawPassword: string) {
const hashed = await hashPassword(rawPassword)
// ...
}密码哈希应该在 service 层完成,repository 只负责存数据:
// src/services/user-service.ts
// ✅ 正确:service 层编排业务,repository 层只做数据操作
import { hashPassword } from './auth-service'
import { createUser } from '../repositories/user-repo'
export async function registerUser(db: D1Database, email: string, rawPassword: string) {
const hashed = await hashPassword(rawPassword)
return createUser(db, email, hashed)
}依赖规则的价值在于:当你在 repositories/ 里看到一个文件,你可以确定它不会反过来依赖 services/ 或 routes/。这种确定性让代码库变得可预测——新人接手项目时,不需要记住「这个 repo 可能偷偷调了 service」这种例外。
4. 层与层之间的通信契约
层之间通过什么来传递数据?类型和接口。
当每个层都独立定义自己接收和返回的数据结构时,层间的耦合就降低了。一个更实际的做法是:在 types/ 目录里定义共享的实体类型,各层都引用同一份类型定义。
// src/types/entities.ts
export type User = {
id: number
email: string
name: string
createdAt: string
}
export type CreateUserInput = {
email: string
name: string
password: string
}// src/repositories/user-repo.ts
import type { User, CreateUserInput } from '../types/entities'
export async function findById(db: D1Database, id: number): Promise<User | null> {
const row = await db.prepare('SELECT * FROM users WHERE id = ?').bind(id).first()
return row as User | null
}
export async function create(db: D1Database, input: CreateUserInput): Promise<User> {
const now = new Date().toISOString()
const result = await db
.prepare('INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)')
.bind(input.email, input.name, now)
.run()
return { id: result.meta.last_row_id as number, ...input, createdAt: now }
}// src/services/user-service.ts
import type { User, CreateUserInput } from '../types/entities'
import { findById, create } from '../repositories/user-repo'
import { hashPassword } from './auth-service'
export async function getUser(db: D1Database, id: number): Promise<User | null> {
return findById(db, id)
}
export async function registerUser(db: D1Database, input: CreateUserInput): Promise<User> {
const hashedInput = { ...input, password: await hashPassword(input.password) }
return create(db, hashedInput)
}// src/routes/users.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types/env'
import { getUser, registerUser } from '../services/user-service'
export const usersApp = new Hono<AppEnv>()
usersApp.get('/:id{\\d+}', async (c) => {
const id = Number(c.req.param('id'))
const db = c.env.DB
const user = await getUser(db, id)
if (!user) {
return c.json({ error: 'User not found' }, 404)
}
return c.json(user)
})
usersApp.post('/', async (c) => {
const body = await c.req.json()
const db = c.env.DB
const user = await registerUser(db, body)
return c.json(user, 201)
})这个例子里,types/entities.ts 就是各层之间的契约。repository 返回 User,service 接收和返回 User,route 也读 User。改一处类型定义,所有引用它的地方都会得到类型检查。
5. 什么时候适合用技术层拆分
技术层拆分不是万能的。它在以下场景下比较合适:
适合的情况:
- 项目的技术边界清晰——路由、业务逻辑、数据访问可以明确分开
- 团队对分层架构有经验,能自觉遵守依赖规则
- 中小规模项目,每层的文件数量可控(每层 5-15 个文件)
- 数据库操作相对标准化,repository 层不会变成纯粹的 SQL 转发器
不太适合的情况:
- 业务逻辑很薄、数据操作也很薄的项目——分三层只是多了一层间接调用
- 团队成员对分层没有共识——repository 里偷偷写了业务逻辑,service 里直接拼 SQL,分层形同虚设
- 项目非常小(5 个以内接口)——一个文件就能装下,拆分带来的组织收益抵不过认知成本
一个判断标准:如果你在 routes/ 里看到了业务判断(比如 if/else 决定用户能否执行某个操作),说明项目已经需要 service 层了。 如果路由处理函数里只有「取参数 → 调数据库 → 返回 JSON」,三层拆分可能暂时不需要。
6. 一个完整的分层结构示例
把前面的片段合在一起,看一个完整的小项目结构:
src/
├── index.ts # 入口:挂中间件、注册路由
├── types/
│ ├── env.ts # AppEnv 类型
│ └── entities.ts # User, Post 等实体类型
├── schemas/
│ ├── user.ts # 请求体校验 schema
│ └── post.ts
├── middleware/
│ ├── auth.ts # 鉴权中间件
│ └── error-handler.ts # 全局错误处理
├── routes/
│ ├── users.ts # 用户路由
│ ├── posts.ts # 文章路由
│ └── auth.ts # 认证路由
├── services/
│ ├── user-service.ts # 用户业务逻辑
│ ├── post-service.ts # 文章业务逻辑
│ └── auth-service.ts # 认证业务逻辑(密码哈希、JWT 签发)
├── repositories/
│ ├── user-repo.ts # 用户数据访问
│ └── post-repo.ts # 文章数据访问
└── utils/
├── errors.ts # 自定义错误类
└── response.ts # 统一响应格式
入口文件负责把它们串起来:
// src/index.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import type { AppEnv } from './types/env'
import { usersApp } from './routes/users'
import { postsApp } from './routes/posts'
import { authApp } from './routes/auth'
import { errorHandler } from './middleware/error-handler'
const app = new Hono<AppEnv>()
app.use('*', logger())
app.use('*', cors())
app.use('*', errorHandler)
app.route('/api/users', usersApp)
app.route('/api/posts', postsApp)
app.route('/api/auth', authApp)
export default app注意入口文件里没有任何业务逻辑。它只做两件事:挂全局中间件、注册路由模块。这是技术层拆分后入口文件的典型形态——它是一张「组装清单」,不是一份「执行脚本」。
7. 技术层拆分的收益
按类型找代码很快
「数据库操作在哪?」——去 repositories/。
「密码校验逻辑在哪?」——去 services/。
「这个接口的入参校验怎么做?」——去 schemas/。
当你熟悉了一个分层项目的结构之后,定位代码的效率比较高。每一类代码都有一个明确的归属目录,不需要在文件树里上下翻找。
层的替换和测试相对独立
如果以后要把 D1 数据库换成 Turso,只需要改 repositories/ 里的文件。services/ 和 routes/ 不需要动——前提是它们之间通过类型契约通信,而不是直接耦合实现细节。
repository 层也更容易单独测试。给它一个 mock 数据库,验证它返回的数据结构是否符合 types/entities.ts 里的定义。service 层的测试同理,可以 mock 掉 repository。
职责边界降低认知负担
新加入团队的开发者看到 routes/users.ts,可以预期这个文件只做 HTTP 协议相关的事——解析请求、调用 service、返回响应。业务逻辑不在这里,SQL 也不在这里。他不需要读完整个文件才能确认这一点。
8. 技术层拆分 vs 业务模块拆分
03 讨论了按业务模块拆分。这两种策略并不互斥,但侧重点不同。
技术层拆分按职责类型组织——所有路由在一起,所有 service 在一起,所有 repository 在一起。
业务模块拆分按功能领域组织——用户模块包含用户的路由、service、repository,文章模块包含文章的路由、service、repository。
对比一下目录结构:
// 技术层拆分
src/
├── routes/
│ ├── users.ts
│ └── posts.ts
├── services/
│ ├── user-service.ts
│ └── post-service.ts
└── repositories/
├── user-repo.ts
└── post-repo.ts
// 业务模块拆分
src/
├── users/
│ ├── routes.ts
│ ├── service.ts
│ └── repository.ts
└── posts/
├── routes.ts
├── service.ts
└── repository.ts
两种结构各有取舍:
| 维度 | 技术层拆分 | 业务模块拆分 |
|---|---|---|
| 找某类代码 | 快,直接进对应目录 | 需要跨目录 |
| 理解某个功能 | 需要跨多个目录 | 一个目录看完 |
| 改某个功能 | 可能改多个目录的文件 | 通常只改一个目录 |
| 新人上手 | 先理解分层规则 | 先理解业务边界 |
| 适合规模 | 小到中 | 中到大 |
技术层拆分的核心问题是:当一个功能需要改动时,你需要在 routes/、services/、repositories/ 之间跳转。功能越多,每个目录下的文件也越多,但单个目录内部的文件之间未必有直接关系——user-service.ts 和 post-service.ts 可能完全不相关,只是恰好都在 services/ 里。
9. 混合策略:在模块内分层
当项目增长到一定规模,纯粹的技术层拆分会遇到瓶颈——每层有几十个文件,跨目录跳转的成本越来越高。
一种折中方案是在业务模块内部做技术层拆分:
src/
├── modules/
│ ├── users/
│ │ ├── routes.ts
│ │ ├── service.ts
│ │ ├── repository.ts
│ │ └── types.ts
│ ├── posts/
│ │ ├── routes.ts
│ │ ├── service.ts
│ │ ├── repository.ts
│ │ └── types.ts
│ └── auth/
│ ├── routes.ts
│ ├── service.ts
│ └── types.ts
├── shared/ # 跨模块共享的基础设施
│ ├── middleware/
│ ├── types/
│ └── utils/
└── index.ts
这种结构把业务模块拆分和技术层拆分结合起来。每个模块内部按技术层组织(routes → service → repository),模块之间有明确的边界,公共能力放在 shared/ 里。
它的依赖规则也更精细:
- 模块内部:
routes → service → repository - 模块之间:不能直接引用对方模块的内部文件,只能通过
shared/提供的共享能力 shared/:不能引用任何模块的内部实现
混合策略兼顾了两种拆分的优点——改某个功能时大部分改动集中在一个模块目录里,同时模块内部保持了分层架构的可预测性。
10. 技术层拆分容易出什么问题
God Folder:某一层膨胀失控
最常见的退化是 services/ 或 repositories/ 变成「上帝目录」——几十个文件堆在一起,职责边界模糊。
比如 services/ 里有 30 个文件,其中一半是用户相关的,但你得在整个 services/ 目录里找。文件多了以后,有人会在 services/ 下建子目录(services/users/、services/posts/),这时候它已经退化成混合策略了。
退化信号:
- 某一层的文件数超过 15-20 个
- 同一层的文件之间开始按业务领域建子目录
- 文件名需要加前缀来区分归属(
user-service.ts、post-service.ts、user-repo.ts……)
分层形式主义
另一种常见问题是分层只体现在目录结构上,代码本身并没有遵守层的职责:
routes/里直接写 SQLrepositories/里有业务判断逻辑services/只是把 repository 的方法透传一遍,没有任何编排
这种情况下,分层没有带来任何实际收益,反而增加了文件跳转的成本。判断标准是:如果你删掉 service 层,routes 能不能直接调 repository 并且逻辑不变? 如果答案是「能」,说明 service 层当前是多余的。
层间传递的类型膨胀
随着功能增多,types/ 目录里的类型定义可能变得很复杂——各种 DTO(Data Transfer Object)、请求/响应类型、实体类型混在一起。
建议按用途进一步细分:
types/
├── entities.ts # 数据库实体(对应表结构)
├── dto.ts # 层间传递的数据对象
├── api.ts # API 请求/响应类型
└── env.ts # 环境变量和绑定类型
延伸阅读
- 02-中大型项目结构 — 按职责拆分的基础实践
- 03-按业务模块拆分 — 另一种组织思路
- Clean Architecture — 分层架构与依赖规则的经典论述
- A Philosophy of Software Design — 模块划分与复杂度管理的系统性讨论
总结
技术层拆分是 Hono 后端项目中最常见的组织策略之一。它的核心思路是:
- 按技术职责分层——
routes/处理 HTTP,services/处理业务逻辑,repositories/处理数据访问 - 遵守依赖规则——上层调下层,不能反向
- 层间通过类型契约通信——共享的类型定义放在
types/里
这种拆分方式在中小规模项目中效果不错。当项目继续增长时,纯粹的技术层拆分会遇到「上帝目录」问题,可以过渡到「在模块内分层」的混合策略。小项目先用技术层拆分建立清晰的职责边界,等项目规模上来后再引入模块维度,是一条比较稳妥的演进路径。
下一篇进入 Routes 层的具体设计——路由文件该怎么组织、处理函数的职责边界在哪里、如何避免路由层膨胀成「什么都做」的入口文件。