小型项目结构
要点
- 项目结构不需要在第一天就设计好,也不该等到代码失控才去想这件事
- 一个 Hono 项目的最小结构只需要一个
index.ts,够用到十几个路由以内 - 单文件应用膨胀到一两百行时,开始出现「找一个路由要上下滚很久」的问题,这就是拆分的信号
- 5 到 10 个文件的小型结构,按职责分三个目录就够:
routes/、middleware/、lib/ - 类型定义集中到
types.ts,环境变量通过AppEnv统一管理,不在各个路由文件里重复声明 - 「刚好够用」原则:只解决当前已经出现的组织问题,不为三个月后的规模提前设计
- 本系列会依次覆盖小型结构、中大型结构、按业务模块拆分、各层职责、类型管理、环境配置等话题
1. 什么时候该开始考虑项目结构
也许你刚搭好一个 Hono 项目,所有代码写在一个 index.ts 里,跑了几天,功能还在增加。这时候心里可能会冒出一个问题:「要不要把代码拆一拆?」
这个问题本身就说明代码已经长到需要认真想了。
反过来说,如果项目还在「五个路由、一个中间件」的阶段,提前花一个小时设计目录结构,大概率是浪费。因为你还没有足够的代码来验证这个结构是否合适,写出来的目录划分更多是凭想象。
那什么时候算「该想了」?几个可以参考的信号:
index.ts膨胀到一两百行,想找一个路由要上下滚很久。- 修改用户路由的时候,不小心影响到认证中间件,因为它们挤在同一个文件里。
- 两个人同时改
index.ts,提交代码时 Git 冲突不断。 - 你想给某个路由加测试,但发现没法单独导入,因为它和其他几十个路由写在一起。
这些信号有一个共同点:代码的组织方式已经开始拖累日常开发效率。 不是「将来可能会出问题」,而是「现在已经在制造摩擦」。
反过来,如果没有这些信号,不用急。单文件不是罪过,过早拆分才是。
2. 最小可行结构
一个 Hono 项目的最小结构,就是一个入口文件。
// src/index.ts
import { Hono } from 'hono'
type Bindings = {
DB: D1Database
JWT_SECRET: string
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/health', (c) => c.json({ status: 'ok' }))
app.get('/api/users', async (c) => {
const db = c.env.DB
const users = await db.prepare('SELECT * FROM users').all()
return c.json(users)
})
app.get('/api/posts', async (c) => {
const db = c.env.DB
const posts = await db.prepare('SELECT * FROM posts').all()
return c.json(posts)
})
export default app这个文件同时承担了类型定义、路由注册、业务逻辑三个职责。项目小的时候这样做完全没问题——代码量不大,一眼就能看到全貌,拆文件反而多出导入导出的心智开销。
但这个阶段不会永远持续。当路由增加、中间件出现、数据库操作变多,一个文件装不下是自然而然的事。
3. 单文件到多文件:什么时候跳
从单文件拆到多文件,不需要等到代码「非常乱」才动手。可以观察两个比较实际的阈值:
- 入口文件超过 150 行。 这个数字不是硬性的,但一个经验规律是,超过这个长度,人类阅读代码时的「一屏可见」优势就消失了。你想确认某个路由的完整逻辑,需要在文件里来回滚动。
- 出现第二种职责的代码。 比如路由定义里混入了参数校验、错误处理、数据库连接管理。当一个文件开始同时处理「接收请求」、「校验输入」、「操作数据库」、「格式化响应」这些事,拆分就有了明确的方向。
不需要等到 500 行再动。提前在 150 行左右拆一下,工作量很小,也不会有太多历史包袱要处理。
拆分的方向也不是固定的。可以按路由拆(用户路由一个文件,文章路由一个文件),也可以按职责拆(中间件一个文件,工具函数一个文件),也可以混着来。关键是先解决当前最痛的问题——哪个部分让你最频繁地滚动、最容易改错——就先拆哪个。
4. 一个实用的 5-10 文件小型结构
当项目从单文件走出来,第一个稳定形态大概是这样的:
src/
index.ts 入口文件,挂载中间件和路由
types.ts 共享类型定义(Bindings、Variables)
routes/
users.ts 用户相关路由
posts.ts 文章相关路由
auth.ts 认证相关路由(登录、注册)
middleware/
auth.ts 鉴权中间件
lib/
response.ts 统一响应格式
wrangler.jsonc Cloudflare Workers 配置
package.json 依赖和脚本一共 9 个文件(不含配置文件),每个文件的职责都很单一。
几个值得说明的设计选择:
入口文件 index.ts 只做组装。 它不写任何业务逻辑,只做两件事:挂全局中间件,注册路由模块。
// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import type { AppEnv } from './types'
import { usersApp } from './routes/users'
import { postsApp } from './routes/posts'
import { authApp } from './routes/auth'
import { authMiddleware } from './middleware/auth'
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)
export default app以后加一个新功能模块,只需要在 routes/ 下新建一个文件,然后在这里加一行 app.route()。入口文件本身不会膨胀。
路由模块各管各的。 每个路由文件创建一个独立的 Hono 实例,处理一组相关接口:
// src/routes/users.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { authMiddleware } from '../middleware/auth'
export const usersApp = new Hono<AppEnv>()
usersApp.get('/', async (c) => {
const db = c.env.DB
const users = await db.prepare('SELECT * FROM users').all()
return c.json(users)
})
usersApp.post('/', authMiddleware, async (c) => {
const body = await c.req.json()
return c.json({ id: 1, ...body }, 201)
})注意这里的路径都是 / 和 /:id,没有写 /api/users。因为入口文件用 app.route('/api/users', usersApp) 把路由挂载到了对应前缀下,所以这个模块里的 / 实际对应 /api/users。这样做的好处是路由模块和路径前缀解耦——哪天想把 /api/users 改成 /v2/users,只需要改入口文件那一行。
中间件独立出来。 middleware/auth.ts 放鉴权相关的中间件,被多个路由模块共享使用。小型项目通常只有一两个中间件,一个文件就够了。
// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../types'
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
return c.json({ error: 'unauthorized' }, 401)
}
// 验证 token,把用户信息存入 c.set()
await next()
})5. 小型项目里的关注点分离
即使是 5-10 个文件的小项目,也可以做基本的关注点分离。这里给出一个实用的三层模型。
路由层:定义「哪个路径做什么」
路由层只做路径匹配和方法分发。它告诉 Hono「收到 POST /api/users 时,先过鉴权中间件,再交给这个处理函数」。路由层不写业务逻辑,不做参数校验的细节。
// src/routes/users.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { authMiddleware } from '../middleware/auth'
import { createUser, getUserById } from '../lib/users'
export const usersApp = new Hono<AppEnv>()
usersApp.post('/', authMiddleware, async (c) => {
const body = await c.req.json()
const result = await createUser(c.env.DB, body)
return c.json(result, 201)
})处理函数:做具体的请求处理
处理函数接收请求、提取数据、调用业务逻辑、返回响应。它关心的是「这个请求的输入是什么、输出是什么」。
当处理函数里的逻辑超过 30 行,或者出现了「从请求里提取参数 → 校验 → 调用数据库 → 格式化响应」这种完整的处理链条时,可以考虑把数据库操作的部分抽到 lib/ 里。
// src/lib/users.ts
import type { DrizzleD1Database } from 'drizzle-orm/d1'
export async function createUser(db: D1Database, input: { name: string; email: string }) {
// 校验输入
if (!input.name || input.name.length < 2) {
throw new Error('name too short')
}
// 写入数据库
const result = await db.prepare('INSERT INTO users (name, email) VALUES (?, ?)')
.bind(input.name, input.email)
.run()
return { id: result.meta.last_row_id, ...input }
}工具层:放可复用的纯函数
lib/ 目录放那些「被多个路由使用,但和 HTTP 请求无关」的代码。比如统一响应格式、错误处理工具、数据转换函数。
// src/lib/response.ts
import type { Context } from 'hono'
export function success<T>(c: Context, data: T) {
return c.json({ ok: true, data })
}
export function error(c: Context, message: string, status = 400) {
return c.json({ ok: false, error: message }, status)
}在小型项目里,这三层不需要严格遵守。如果一个路由的处理函数只有 10 行,完全可以把逻辑内联,不必强行抽到 lib/。分层的目标是让代码更容易找到和理解,不是为了制造仪式感。
判断标准可以简单概括:如果一段代码只在一个路由里用,留在路由文件里;如果在两个以上路由里用,移到 lib/。
6. 类型、Schema、工具函数的位置
类型定义
前面的路由模块代码里都用到了 AppEnv 类型。如果每个文件都写一遍 type Bindings = { DB: D1Database },改一个环境变量要改好几个文件。集中放到 types.ts,改一处全局生效:
// src/types.ts
export type AppEnv = {
Bindings: {
JWT_SECRET: string
API_KEY: string
DB: D1Database
KV: KVNamespace
}
Variables: {
user: {
id: number
email: string
role: string
}
}
}其他文件只需要 import type { AppEnv } from '../types',然后 new Hono<AppEnv>() 就能拿到完整的类型提示。
校验 Schema
如果项目引入了 Zod 做请求校验,schema 定义放在哪里取决于使用范围:
- 只在一个路由里用的 schema — 和路由写在同一个文件里,不需要单独抽出去。
- 多个路由共享的 schema(比如分页参数、通用 ID 校验) — 放到
lib/schemas.ts,需要时导入。
// src/routes/posts.ts
// 只在这里用,直接写在路由文件里
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
})// src/lib/schemas.ts
// 多个路由共享,集中放
import { z } from 'zod'
export const paginationSchema = z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(20),
})
export const idParamSchema = z.object({
id: z.string().regex(/^\d+$/).transform(Number),
})不要一开始就把所有 schema 集中管理。等到真正出现跨文件复用的需求再抽,过早集中会增加文件之间的耦合。
工具函数
和 schema 同理:先用后抽。一个工具函数只在一处使用时,留在原地;两处以上使用时,移到 lib/。
// src/lib/utils.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
}7. 环境配置基础
小型项目的环境配置不需要太复杂,但有两个基本的边界需要划清楚。
不怕泄露的配置,直接写在 wrangler.jsonc 的 vars 里,跟代码一起提交到 Git:
// wrangler.jsonc
{
"name": "my-api",
"main": "src/index.ts",
"compatibility_date": "2024-12-01",
"vars": {
"APP_NAME": "My API",
"ALLOWED_ORIGINS": "https://example.com"
}
}不能泄露的密钥,用 wrangler secret 命令单独设置,不出现在任何文件里:
// terminal
wrangler secret put JWT_SECRET
wrangler secret put API_KEY执行后提示你输入值。这些密钥加密存储在 Cloudflare 的服务器上,你在控制台也只能看到「已设置」,看不到具体内容。
两种方式设置的环境变量,代码里的用法完全一样,都是 c.env.变量名:
// src/routes/auth.ts
app.post('/login', async (c) => {
const secret = c.env.JWT_SECRET // 来自 wrangler secret
const appName = c.env.APP_NAME // 来自 vars
// 用法完全一样
})本地开发密钥
wrangler secret 设置的密钥存在 Cloudflare 云端。本地 wrangler dev 时不走云端,拿不到这些密钥。解决办法是在项目根目录创建 .dev.vars 文件:
// .dev.vars
JWT_SECRET=local-dev-secret-key
API_KEY=local-dev-api-keywrangler dev 启动时会自动读取这个文件。这个文件有密钥,一定要加到 .gitignore:
// .gitignore
.dev.vars
.wrangler/
node_modules/关于多环境配置(staging / production)的详细内容,会在本系列后续的环境配置专题中展开。
8. 「刚好够用」原则
前面给出的结构,核心只有一条原则:只解决当前已经出现的组织问题。
不把路由文件按「controller / service / repository」再拆三层——除非处理函数已经长到需要这样拆。
不提前建 lib/errors/、lib/validators/、lib/constants/ 这些目录——除非真的出现了需要集中管理的内容。
不引入抽象层——除非有两处以上代码需要复用同一段逻辑。
一个常见的反模式是:在写第一个功能之前,先建好十几个目录,每个目录放一个空的 index.ts。这种做法把「未来可能需要」的假设提前变成了「现在必须遵守」的约束。结果是每新增一个功能都要在多个目录之间创建文件、写导入导出,但这些开销在项目初期没有任何回报。
更好的做法是「先用后抽」:
- 代码写在当前最方便的位置(通常是路由文件里)。
- 当同一段逻辑出现在两个地方时,考虑抽到
lib/。 - 当路由文件超过 100 行时,考虑把业务逻辑抽到处理函数。
- 当类型定义开始重复时,考虑集中到
types.ts。
每次拆分都有具体的代码作为依据,而不是凭想象预判。这样做出来的结构,每一层、每个文件都有真实的需求在支撑。
9. 什么时候小型结构不够用了
小型结构能撑到一定规模,但不会是永久的。以下几个信号表明项目需要进一步拆分:
routes/目录下超过 8-10 个文件,且单个文件超过 200 行。 路由之间开始出现功能重叠,比如用户路由里混入了用户权限管理,文章路由里混入了评论管理。- 某个路由文件同时承担了路由定义、参数校验、数据库操作、响应格式化四个职责,且代码超过 150 行。 改一个校验规则要在同一个文件里找到对应的数据库操作。
types.ts膨胀到 100 行以上。 类型定义的种类太多(环境变量、请求体、响应体、数据库行、错误码),放在一起不好找。lib/下开始出现功能分组。 比如lib/auth.ts管认证、lib/db.ts管数据库、lib/cache.ts管缓存——这说明lib/本身需要按子目录拆分。- 团队超过两个人,开始按功能分工。 两个人同时改
routes/users.ts的概率比同时改index.ts低得多,但如果路由文件本身太大,冲突还是会出现。
这些信号出现时,可以考虑进入中大型项目结构。但不是直接跳到复杂的企业级架构,而是顺着已经出现的分组做进一步拆分——把相关的几个路由合到一个「模块」里,每个模块有自己的路由、处理函数、类型定义。
这个方向会在 03 中详细展开。
10. 本系列的路线图
这个系列会从项目结构的最小形态出发,逐步展开到中大型项目的组织方式。大致覆盖以下内容:
- 01 小型项目结构(本文)— 最小可行结构、单文件到多文件的拆分时机、「刚好够用」原则
- 02 中大型项目结构 — 完整目录组织、入口文件只做组装、类型集中管理、环境配置和多环境部署
- 03 按业务模块拆分 — 从按职责拆分到按业务域拆分,模块边界的划分依据
- 后续各篇 — 各层职责细分、类型管理策略、测试与脚本组织
每篇都会给出具体的目录结构和代码示例,不追求穷举所有可能的组织方式,而是给出一条务实的演进路径:从最小结构出发,按真实需求逐步增加层次。
延伸阅读
- 02-中大型项目结构 — 完整的中等规模目录组织,包括 wrangler 配置和多环境部署
- Hono 官方文档 - 中间件 — Hono 内置中间件的使用方式
- Hono 官方文档 - Router — 路由注册和
app.route()的用法
总结
这篇讨论了一个 Hono 项目从小到中的结构演进:
- 最小形态 — 一个
index.ts搞定一切,够用到十几个路由 - 拆分信号 — 入口文件超过 150 行、出现多种职责混在一起、协作产生冲突
- 小型结构 — 按职责分
routes/、middleware/、lib/三个目录,入口文件只做组装 - 关注点分离 — 路由层管路径匹配,处理函数管请求处理,工具层管复用逻辑
- 演进原则 — 「先用后抽」,每次拆分都有真实代码作为依据
核心判断标准是:结构复杂度跟着代码复杂度走,不提前设计,也不拖延到失控。
下一篇进入中大型项目结构,看目录层次如何进一步展开,以及环境配置和多环境部署的具体做法。