按技术层拆分

要点

  • 按技术层拆分是把代码按职责分组——路由放 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)。

经典的三层模型把系统分成:

  1. 表现层(Presentation):负责和用户交互——接收输入、格式化输出。在 Web API 里就是路由和控制器
  2. 业务层(Business / Domain):负责业务规则和逻辑——计算、校验、状态转换
  3. 数据层(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.tspost-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/ 里。

它的依赖规则也更精细:

  1. 模块内部:routes → service → repository
  2. 模块之间:不能直接引用对方模块的内部文件,只能通过 shared/ 提供的共享能力
  3. shared/:不能引用任何模块的内部实现

混合策略兼顾了两种拆分的优点——改某个功能时大部分改动集中在一个模块目录里,同时模块内部保持了分层架构的可预测性。

10. 技术层拆分容易出什么问题

God Folder:某一层膨胀失控

最常见的退化是 services/repositories/ 变成「上帝目录」——几十个文件堆在一起,职责边界模糊。

比如 services/ 里有 30 个文件,其中一半是用户相关的,但你得在整个 services/ 目录里找。文件多了以后,有人会在 services/ 下建子目录(services/users/services/posts/),这时候它已经退化成混合策略了。

退化信号:

  • 某一层的文件数超过 15-20 个
  • 同一层的文件之间开始按业务领域建子目录
  • 文件名需要加前缀来区分归属(user-service.tspost-service.tsuser-repo.ts……)

分层形式主义

另一种常见问题是分层只体现在目录结构上,代码本身并没有遵守层的职责:

  • routes/ 里直接写 SQL
  • repositories/ 里有业务判断逻辑
  • services/ 只是把 repository 的方法透传一遍,没有任何编排

这种情况下,分层没有带来任何实际收益,反而增加了文件跳转的成本。判断标准是:如果你删掉 service 层,routes 能不能直接调 repository 并且逻辑不变? 如果答案是「能」,说明 service 层当前是多余的。

层间传递的类型膨胀

随着功能增多,types/ 目录里的类型定义可能变得很复杂——各种 DTO(Data Transfer Object)、请求/响应类型、实体类型混在一起。

建议按用途进一步细分:

types/
├── entities.ts      # 数据库实体(对应表结构)
├── dto.ts           # 层间传递的数据对象
├── api.ts           # API 请求/响应类型
└── env.ts           # 环境变量和绑定类型

延伸阅读

总结

技术层拆分是 Hono 后端项目中最常见的组织策略之一。它的核心思路是:

  1. 按技术职责分层——routes/ 处理 HTTP,services/ 处理业务逻辑,repositories/ 处理数据访问
  2. 遵守依赖规则——上层调下层,不能反向
  3. 层间通过类型契约通信——共享的类型定义放在 types/

这种拆分方式在中小规模项目中效果不错。当项目继续增长时,纯粹的技术层拆分会遇到「上帝目录」问题,可以过渡到「在模块内分层」的混合策略。小项目先用技术层拆分建立清晰的职责边界,等项目规模上来后再引入模块维度,是一条比较稳妥的演进路径。

下一篇进入 Routes 层的具体设计——路由文件该怎么组织、处理函数的职责边界在哪里、如何避免路由层膨胀成「什么都做」的入口文件。