1. 从“t3code”这个名字说起:它到底指什么
第一次看到“t3code”这个词,很多人会一头雾水。它不像“React”“Docker”那样有明确的官方定义,也不像“Python”那样指向一门具体的编程语言。我在几个技术社区翻了一圈,发现这个词的用法相当分散:有人拿它当某个内部工具链的代号,有人用它指代一套轻量级的编码规范,还有人把它当成“TypeScript + 3层架构 + code”的缩写来用。这种模糊性恰恰是它有意思的地方——它更像一个“约定俗成”的标签,而不是一个注册商标式的产品名。
我个人的判断是,t3code 最合理的理解方式,是把它看作一套“以三层结构组织 TypeScript 代码”的实践集合。这里的“t3”我倾向于解读为 three-tier(三层),而“code”就是代码本身。为什么这么解读?因为在实际项目里,凡是自称用 t3code 风格的团队,几乎都在做同一件事:把代码按“数据访问层 / 业务逻辑层 / 接口层”切开,并且用 TypeScript 的类型系统把这三层之间的边界焊死。这套做法解决的核心问题是——当项目从几千行膨胀到几万行时,代码不会变成一锅粥。
它适合谁?如果你正在写一个中小型的全栈项目,后端用 Node.js 或 Bun,前端用 React 或 Vue,并且你已经被“改一个字段要动八个文件”这种事折磨过,那 t3code 这套思路值得你花时间琢磨。它不要求你引入什么重型框架,也不需要你重构整个项目,更多是一种“组织代码的纪律”。下面我会从它解决的问题、核心分层逻辑、类型边界设计、实操落地步骤、常见坑这几个角度,把这件事讲透。
2. 为什么需要 t3code:三层结构解决的真实痛点
2.1 一个字段改动引发的“蝴蝶效应”
先讲一个我亲身经历的场景。早些年我参与过一个后台管理系统,技术栈是 Express + TypeScript + React。项目初期只有五六个接口,代码写得很随意:路由处理函数里直接写 SQL 查询,查出来的数据顺手在同一个函数里做业务判断,然后直接res.json()返回。前两个月一切正常,直到产品要求给“用户列表”加一个“最近登录时间”字段。
按理说这是个五分钟的活。但实际改起来是这样的:先在路由文件里改 SQL,然后发现前端拿到的字段名和数据库列名不一致,得在返回前手动映射;映射逻辑又和另一个接口重复了,改了一处忘了另一处;改完之后 TypeScript 没报错,因为返回类型写的是any;上线后前端某个页面崩了,因为那个页面假设返回对象里没有这个字段,做了Object.keys遍历渲染。一个字段,折腾了一下午。
这个场景的本质问题是:数据从数据库到界面,中间没有清晰的“关卡”。每一层都可以随意改数据形状,每一层都不对数据的最终形态负责。t3code 的三层结构,就是在这条链路上设三道关卡,每道关卡只干一件事,并且用类型把“进出口”的货物形状写死。
2.2 三层各自该干什么、不该干什么
我把这三层的职责用一句话概括,你可以贴在显示器边上:
- 数据访问层(Data Layer):只负责和数据库或外部 API 打交道,把原始数据取出来,转成领域对象。它不关心业务规则,不关心 HTTP 状态码。
- 业务逻辑层(Service Layer):只负责业务规则。比如“用户积分满 1000 才能兑换”“订单超过 24 小时未支付要自动取消”。它不碰 SQL,也不碰
req和res。 - 接口层(Controller / Route Layer):只负责解析请求、调用业务层、组装响应。它不写业务判断,也不直接查库。
这三层之间通过明确的函数签名通信,而不是通过“共享一个巨大的对象”通信。这是 t3code 和“随便分三层”最大的区别。很多项目也分文件夹叫dao、service、controller,但层与层之间传的是any或者一个包含几十个字段的User大对象,结果业务层偷偷改了对象的某个字段,接口层根本不知道。t3code 要求每一层定义自己的输入输出类型,层与层之间做显式转换。
2.3 和“不分层”以及“过度分层”的对比
为了让你更清楚 t3code 的定位,我做个简单的对比:
| 做法 | 典型表现 | 问题 |
|---|---|---|
| 不分层 | 路由函数里写 SQL + 业务 + 响应 | 改一处动全身,无法测试 |
| 过度分层 | 每层再拆 Repository、Manager、Handler、Mapper | 一个简单查询要跳七八个文件 |
| t3code 三层 | 数据、业务、接口各一层,类型显式转换 | 平衡,改动影响范围可控 |
过度分层我见过最夸张的案例:一个“根据 ID 查用户”的功能,从 Controller 到最终 SQL 要经过 Controller → Handler → Service → Manager → Repository → DAO 六层,每层只是把参数原样传递。这种分层除了增加文件数量,没有任何实际收益。t3code 的三层是“够用就好”的分法,每一层都有不可替代的职责,不存在纯转发的空层。
3. 类型边界:t3code 真正的技术核心
3.1 为什么“类型”比“文件夹”更重要
很多人以为分层就是把文件放进不同文件夹,这是最大的误解。文件夹只是物理隔离,类型才是逻辑隔离。我见过太多项目,service文件夹里的代码直接import了dao文件夹里的类型定义,然后拿着数据库实体到处跑。这种情况下,数据库加一个字段,业务层和接口层的类型全跟着变,分层形同虚设。
t3code 的做法是:每一层定义自己的数据类型,层与层之间通过映射函数转换。数据库实体叫UserRow,业务层用的叫User,接口层返回给前端的叫UserResponse。这三个类型字段可能大部分相同,但它们是独立的,各自只包含本层需要的信息。这样做的好处是,数据库加了一个内部字段(比如deleted_at),业务层的User类型根本不需要知道,接口层更不会把它返回给前端。
3.2 用 TypeScript 的映射类型减少重复
你可能会说:那我不是要写三遍几乎一样的类型定义?手写确实烦,但 TypeScript 提供了工具类型来减少重复。比如:
// 数据库实体 type UserRow = { id: number; name: string; email: string; password_hash: string; created_at: Date; deleted_at: Date | null; }; // 业务层类型:用 Pick 和 Omit 裁剪 type User = Omit<UserRow, 'password_hash' | 'deleted_at'>; // 接口层响应类型:再裁掉内部字段 type UserResponse = Omit<User, 'created_at'> & { createdAt: string; // 前端要字符串格式 };这样写,字段的增删改只需要在UserRow一处维护,其他层通过Omit、Pick、Partial自动派生。但要注意,派生关系要显式写出来,不能靠“结构相同”来隐式兼容。TypeScript 的结构化类型系统有个坑:两个字段完全相同的类型可以互相赋值,哪怕它们名字不同。所以我在项目里会加一个brand字段来强制区分:
type UserId = number & { __brand: 'UserId' }; type OrderId = number & { __brand: 'OrderId' };这样UserId和OrderId就不能互相赋值了,避免把用户 ID 当订单 ID 传进去这种低级错误。这个技巧在 t3code 的接口层特别有用,因为接口层经常要处理各种 ID。
3.3 层间转换函数的写法与放置位置
转换函数放哪里?我的习惯是放在被调用层的入口文件里。比如数据层返回UserRow,业务层需要User,那转换函数toUser(row: UserRow): User就放在业务层文件里。为什么?因为业务层最清楚自己需要哪些字段,让业务层主动“挑选”数据,而不是让数据层“塞”数据。这符合依赖倒置的原则。
转换函数本身要写得“笨”一点,不要搞什么自动映射。我见过用Object.keys遍历自动拷贝的转换函数,看起来很聪明,但一旦字段名不一致(比如created_at要转成createdAt),自动映射就歇菜了。手写映射虽然啰嗦,但每一行都是明确的意图,出错了也容易定位。一个典型的转换函数长这样:
function toUser(row: UserRow): User { return { id: row.id, name: row.name, email: row.email, createdAt: row.created_at.toISOString(), }; }就这么简单,但它是 t3code 分层能落地的关键。没有这层转换,分层就是自欺欺人。
4. 落地实操:从零搭一个 t3code 风格的项目骨架
4.1 目录结构与依赖选择
我以 Node.js + TypeScript + Express 为例,给一个可以直接抄的目录结构:
src/ data/ user.data.ts // 数据访问层 db.ts // 数据库连接 service/ user.service.ts // 业务逻辑层 types.ts // 业务层类型 api/ user.api.ts // 接口层 types.ts // 接口层类型 index.ts依赖方面,我强烈建议只装必要的:express、typescript、@types/express、@types/node。数据库驱动看你用什么,PostgreSQL 用pg,MySQL 用mysql2,SQLite 用better-sqlite3。不要一上来就上 ORM,t3code 的数据层用原生 SQL 反而更清晰,因为 SQL 写出来就是“数据访问”这件事本身,没有隐藏逻辑。
提示:如果你团队里有人不熟悉 SQL,可以用 Kysely 这类类型安全的查询构建器,它生成的 SQL 可预测,类型也能和 TypeScript 对上。但不要用那种“全自动”的 ORM,它会模糊数据层和业务层的边界。
4.2 数据层:只做取数和转换
数据层的函数命名我习惯用find、insert、update、delete开头,一眼能看出是数据操作。每个函数只做一件事:执行 SQL,把结果转成Row类型返回。它不判断“用户是否存在”,那是业务层的事。
// data/user.data.ts import { db } from './db'; export type UserRow = { id: number; name: string; email: string; password_hash: string; created_at: Date; }; export async function findUserById(id: number): Promise<UserRow | null> { const result = await db.query('SELECT * FROM users WHERE id = $1', [id]); return result.rows[0] ?? null; } export async function insertUser( name: string, email: string, passwordHash: string ): Promise<UserRow> { const result = await db.query( 'INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING *', [name, email, passwordHash] ); return result.rows[0]; }注意findUserById返回UserRow | null,把“可能找不到”这件事用类型表达出来,业务层就必须处理null的情况,不能假装它一定存在。
4.3 业务层:规则集中地
业务层是 t3code 里最“厚”的一层,所有判断逻辑都堆在这里。比如“注册用户”这个业务,要检查邮箱是否已存在、密码强度是否够、然后才调用数据层插入。
// service/user.service.ts import { findUserByEmail, insertUser } from '../data/user.data'; import type { User } from './types'; export async function registerUser( name: string, email: string, password: string ): Promise<User> { if (password.length < 8) { throw new Error('PASSWORD_TOO_SHORT'); } const existing = await findUserByEmail(email); if (existing) { throw new Error('EMAIL_ALREADY_EXISTS'); } const hash = await hashPassword(password); const row = await insertUser(name, email, hash); return toUser(row); }这里抛出的错误是业务错误码,不是 HTTP 状态码。业务层不知道 HTTP 的存在,它只表达“这件事为什么没做成”。接口层拿到EMAIL_ALREADY_EXISTS之后,再决定返回 409 还是 400。这种解耦让业务层可以被 CLI 工具、定时任务、消息队列消费者复用,而不只是给 HTTP 接口用。
4.4 接口层:薄到不能再薄
接口层的函数应该短到一眼看完。它的工作只有三件:解析输入、调用业务、组装输出。
// api/user.api.ts import { Router } from 'express'; import { registerUser } from '../service/user.service'; const router = Router(); router.post('/users', async (req, res) => { const { name, email, password } = req.body; try { const user = await registerUser(name, email, password); res.status(201).json({ id: user.id, name: user.name, email: user.email, createdAt: user.createdAt, }); } catch (err) { if (err instanceof Error && err.message === 'EMAIL_ALREADY_EXISTS') { res.status(409).json({ error: '邮箱已被注册' }); return; } if (err instanceof Error && err.message === 'PASSWORD_TOO_SHORT') { res.status(400).json({ error: '密码至少 8 位' }); return; } res.status(500).json({ error: '服务器内部错误' }); } }); export default router;注意接口层没有直接import数据层的任何东西,它只和业务层打交道。这就是分层的纪律:只能上层调下层,不能跨层调用,也不能下层调上层。
5. 踩过的坑:t3code 落地时最容易翻车的几个地方
5.1 业务层偷偷返回了数据库实体
这是最常见的翻车方式。业务层函数写着写着,为了省事,直接把UserRow返回出去了,接口层拿到UserRow之后,password_hash字段就暴露在前端响应里了。这种 bug 不会报错,因为 TypeScript 结构化类型允许UserRow赋值给User(如果字段兼容的话),但安全漏洞已经埋下了。
我的防御手段是:业务层的返回类型必须显式标注,并且和数据层类型不兼容。比如User类型里根本没有password_hash字段,那UserRow就不能直接赋值给User,TypeScript 会报错。前提是你别用as强转,也别把类型写成any。
5.2 事务边界放错了层
涉及多表写入的业务,比如“下单扣库存”,需要事务。事务应该放在哪一层?我的答案是业务层。因为事务的边界是由业务规则决定的,不是由数据访问决定的。数据层只提供“在事务中执行”的能力,但什么时候开启事务、什么时候提交,由业务层说了算。
// service/order.service.ts export async function placeOrder(userId: number, productId: number) { return await db.transaction(async (tx) => { const stock = await findStockForUpdate(tx, productId); if (stock <= 0) throw new Error('OUT_OF_STOCK'); await decrementStock(tx, productId); return await insertOrder(tx, userId, productId); }); }这里tx是事务句柄,数据层函数接受一个可选的tx参数。如果业务层不传tx,数据层就用默认连接。这种设计让数据层保持“无状态”,事务的控制权在业务层手里。
5.3 循环依赖:三层之间互相 import
三层结构如果不好好管理 import 方向,很容易出现dataimportservice,serviceimportapi这种反向依赖,然后打包时报循环依赖警告。我的做法是在项目里加一个 ESLint 规则,限制 import 路径:
{ "rules": { "no-restricted-imports": ["error", { "patterns": [ { "group": ["**/service/**"], "message": "数据层不能引用业务层" }, { "group": ["**/api/**"], "message": "业务层不能引用接口层" } ] }] } }这个规则配合目录结构,能从工具层面强制分层纪律。人总会偷懒,但工具不会。
5.4 类型定义散落各处导致“找类型”困难
一开始我把类型定义放在各层自己的文件里,结果一个User类型在三个文件里各写了一遍,改一个字段要改三处。后来我改成:共享的领域类型放在service/types.ts,各层特有的类型放在各层目录下。比如UserRow只在数据层用,就放data/user.data.ts;User被业务层和接口层共用,就放service/types.ts;UserResponse只在接口层用,就放api/types.ts。这样既避免了重复,又保持了各层的独立性。
6. 测试与维护:分层之后怎么验证没白分
6.1 业务层可以脱离数据库测试
分层最大的收益之一,就是业务层可以单独测试。因为业务层依赖的是数据层的函数,而函数可以被 mock。用 Vitest 或 Jest 的vi.mock/jest.mock,把数据层整个替换掉:
import { vi, describe, it, expect } from 'vitest'; import { registerUser } from './user.service'; import * as userData from '../data/user.data'; vi.mock('../data/user.data'); describe('registerUser', () => { it('邮箱已存在时抛出错误', async () => { vi.mocked(userData.findUserByEmail).mockResolvedValue({ id: 1, name: 'a', email: 'a@b.com', password_hash: 'x', created_at: new Date(), }); await expect(registerUser('b', 'a@b.com', '12345678')) .rejects.toThrow('EMAIL_ALREADY_EXISTS'); }); });这个测试不连数据库,跑起来毫秒级。如果业务逻辑和数据访问混在一起,这种测试根本写不出来。
6.2 接口层用 supertest 做集成测试
接口层的测试用 supertest 直接打 HTTP,验证状态码和响应体。因为接口层很薄,测试用例也简单:
import request from 'supertest'; import app from '../index'; it('注册成功返回 201', async () => { const res = await request(app) .post('/users') .send({ name: 'test', email: 't@t.com', password: '12345678' }); expect(res.status).toBe(201); expect(res.body).not.toHaveProperty('password_hash'); });最后那个断言特别重要,它验证了接口层没有泄露敏感字段。每次改完接口层,跑一遍这个测试,心里踏实。
6.3 数据层用真实数据库做冒烟测试
数据层的测试我建议连真实数据库(用 Docker 起一个临时实例),因为 SQL 的正确性只有真实数据库能验证。测试内容就是“插入后能查到”“更新后字段变了”这种基本操作。不要 mock 数据库,mock 出来的 SQL 测试没有意义。
7. 这套东西适合什么规模的项目
t3code 这套三层结构,我个人的经验是:代码量在 3000 行到 50000 行之间时收益最大。低于 3000 行,分层带来的文件跳转成本可能超过收益,一个文件写完反而更快。高于 50000 行,三层可能不够用,需要引入领域驱动设计里的聚合根、领域事件等更细的划分。
另外,团队人数也是个参考。单人项目可以随意一点,但只要有两个人以上协作,分层就是刚需。因为分层本质上是一种“接口契约”,它让两个人可以同时改不同的层而不冲突。我见过两个开发者因为都在同一个路由文件里改代码而天天合并冲突,分层之后各改各的,冲突少了一大半。
最后说一个我自己的习惯:每次新建一个业务功能,我会先在纸上画三个框,分别写“数据怎么取”“规则是什么”“接口长什么样”,然后再动手写代码。这个习惯坚持了几年,帮我省下了大量重构时间。t3code 不是什么高深的技术,它就是把“想清楚再写”这件事,用目录结构和类型系统固化下来了。