本节目标:读完这一节,你能说清「按类型拆」和「按功能拆」各自的代价;能画出一个前后端项目里各层之间的依赖箭头并保证它单向;能把 DTO、领域模型、视图模型三种类型分开摆放,而不是混成一个巨大的
any;还能一眼认出循环依赖、跨层直连、上帝模块这三种最常见的结构腐化信号,并知道怎么修。
17.1 项目结构与分层设计
前 16 章我们把 TypeScript 的语言能力、类型工具、模块机制、测试与构建都过了一遍。从这一节开始进入第 17 章——全栈项目实战:把零散的知识点拼成一个真的能交付、能维护、能上线的项目。
这一节先解决最上游的问题:代码放在哪里。这个问题听起来像是「审美」,但在一个活过三个月的项目里,它决定了你能否在五分钟内定位一个 bug,也决定了新人第一天能不能读懂代码。
结构问题为什么会在第三个月爆发
第 2 章我们从零跑通过第一个项目,那时所有代码写在一个 index.ts 里完全没问题。问题出在规模上,可以分成三个阶段:
| 阶段 | 文件规模 | 结构的价值 |
|---|---|---|
| 单文件期 | 1~2 个文件 | 无关紧要,怎么放都能跑 |
| 多文件期 | 10~30 个文件 | 需要目录,但怎么分都还能接受 |
| 多模块期 | 100 个文件以上 | 结构直接决定定位成本与改动风险 |
TypeScript 在这个过程里扮演了一个特殊角色:它把结构问题从「运行时才发现」提前到「编译时报错」。结构混乱的 JavaScript 项目是「能跑,但没人敢改」;换成 TypeScript,跨层引用往往直接编译不过——这反而逼着你在写代码的那一刻就把边界画清楚。
所以本节讲的不是「最佳实践清单」,而是为什么某些结构会让类型系统帮上你,某些结构会让类型系统失效。
三种组织方式的取舍
在动手建目录之前,先看清楚三种主流思路各自的代价:
| 组织方式 | 目录示例 | 优点 | 代价 | 适合规模 |
|---|---|---|---|---|
| 按类型 | types/、utils/、services/ | 一眼看出文件属于哪一类 | 改一个功能要横跨五个目录 | 20 个文件以内 |
| 按功能 | user/、order/、payment/ | 改动局部化,删除功能等于删目录 | 要额外约定「共享代码放哪」 | 中大型项目 |
| 按层 | presentation/、domain/、infrastructure/ | 依赖方向清晰,可测试 | 小项目里显得空 | 有明确领域的项目 |
真实项目几乎不会只用一种。最常见的组合是:顶层按层,层内按功能。例如 domain/user/、application/order/——层负责约束依赖方向,功能负责限制单个目录的体积。
分层的本质是依赖方向
很多人以为分层就是「把代码切成几堆」,其实分层的全部价值只在于一条规则:
依赖必须单向:外层可以依赖内层,内层永远不知道外层的存在。
把这条规则具体到一张表里,就是下面这样:
| 层 | 可以依赖 | 绝不允许依赖 |
|---|---|---|
| interfaces(控制器、路由、CLI) | application 的用例接口 | 数据库驱动、ORM 的 Model |
| application(用例、编排) | domain 的类型、application 的端口接口 | Express/Fastify 的 Request 对象 |
| domain(模型、纯逻辑) | 无——只有纯 TS 类型与纯函数 | 任何 IO、框架、第三方 SDK |
| infrastructure(数据库、外部 API) | domain 与 application 的接口 | interfaces 层 |
为什么值得为这条规则付出「多写几层文件」的代价?两个收益:
- 可测试性。用例只依赖接口,测试时注入一个内存实现就能跑,不需要数据库和 HTTP 服务。
- 可替换性。把 Prisma 换成 Drizzle,改动只发生在 infrastructure 层,domain 与 application 一行不动。
如果你已经读过 架构专题的六边形架构与整洁架构 ,会发现这就是它的最小版本。
一个可以照抄的目录结构
下面这棵树是一个真实项目可以直接使用的骨架,注意每一层的注释说明了它能依赖谁:
src/
├── domain/ # 纯类型 + 纯逻辑,零外部依赖
│ ├── user.ts
│ └── order.ts
├── application/ # 用例编排,只依赖 domain
│ ├── ports/ # 接口(端口),由外层实现
│ │ └── user-repository.ts
│ └── use-cases/
│ └── register-user.ts
├── infrastructure/ # 实现端口,唯一接触 IO 的地方
│ ├── db/
│ │ └── prisma-user-repository.ts
│ └── http/
│ └── fetch-user-api.ts
├── interfaces/ # 面向外部世界:HTTP 路由、CLI 入口
│ └── http/
│ ├── routes.ts
│ └── dto.ts
└── shared/ # 跨层共享的纯工具与基础类型
└── result.ts
「ports(端口)」这个词来自六边形架构:接口定义在内层,实现挂在外层。下一节我们会看到,这个模式和前后端共享类型的做法是同一套思路——契约由使用方定义,实现方去满足它。
层与层之间传什么:DTO、领域模型、视图模型
目录分好了,接下来的问题是:同一个「用户」,在各层之间传的时候用什么类型?零基础项目最常见的错误是一个 User 类型贯穿到底,然后在数据库、API、UI 三处不断打补丁。正确的做法是至少区分三种类型。
第一种,HTTP 边界的 DTO(Data Transfer Object),它描述的是「网络上长什么样」:
// interfaces/http/dto.ts —— 只描述 HTTP 边界,字段与 JSON 一一对应
export interface RegisterUserRequestDto {
email: string;
password: string;
}
export interface UserResponseDto {
id: string;
email: string;
createdAt: string; // 注意:JSON 里日期是字符串,不是 Date
}
第二种,领域模型,它描述的是「业务概念长什么样」:
// domain/user.ts —— 领域模型,可以用 Date 这种真实的运行时类型
export interface User {
readonly id: string;
readonly email: string;
readonly createdAt: Date;
}
第三种,视图模型(ViewModel),描述「界面上要显示什么」——比如列表页只需要 displayName 和 avatarUrl,不需要 passwordHash。三者之间的转换要写成显式函数:
// application/use-cases/register-user.ts
import type { User } from "../../domain/user";
import type { UserResponseDto } from "../../interfaces/http/dto";
export function toResponseDto(user: User): UserResponseDto {
return {
id: user.id,
email: user.email,
createdAt: user.createdAt.toISOString(), // Date -> string 显式转换
};
}
为什么值得多写一个转换函数,而不是直接让 User 同时承担两个角色?
| 理由 | 说明 |
|---|---|
| 变化频率不同 | DTO 受外部契约约束,领域模型可自由重构 |
| 类型不同 | 同一字段在两侧可能是 Date 与 string |
| 安全 | 领域模型上可能有不该出现在响应里的字段 |
依赖倒置:接口定义在内层
现在看最反直觉的一步。按「依赖单向」的规则,application 层不能依赖 infrastructure 层——但用例明明要读数据库。解决办法是把接口定义在内层,实现放在外层:
// application/ports/user-repository.ts —— 接口属于内层
import type { User } from "../../domain/user";
export interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<User>;
}
// infrastructure/db/prisma-user-repository.ts —— 实现属于外层
import type { User } from "../../domain/user";
import type { UserRepository } from "../../application/ports/user-repository";
export class PrismaUserRepository implements UserRepository {
async findByEmail(email: string): Promise<User | null> {
// 真实实现里这里是数据库查询,此处省略
return null;
}
async save(user: User): Promise<User> {
return user;
}
}
箭头在这里「倒」了过来:不是 application 去 import infrastructure,而是 infrastructure 反过来 import application 里的接口。这个技巧就叫依赖倒置。它和 6.3 讲的 implements 是同一件事——如果你对 implements 还不熟,建议回头看一眼 接口实现与 mixin
。
倒置之后,测试变得极其便宜,因为只要满足接口就能替换实现:
// 测试里的内存实现,不需要数据库
import type { UserRepository } from "../application/ports/user-repository";
const inMemoryRepo: UserRepository = {
async findByEmail() {
return null;
},
async save(u) {
return u;
},
};
这里有一个类型系统的隐形保障:inMemoryRepo 被显式标注为 UserRepository,一旦接口新增一个方法,这个对象会立刻编译报错,你不可能忘记补实现。这就是「结构让类型系统帮上你」的具体含义。
三种最常见的结构腐化信号
结构不是一次性设计出来的,它是会腐烂的。下面三种信号一旦出现,就说明该动手了。
第一种:循环依赖。 两个模块互相 import,TypeScript 有时不会报错,但运行时会拿到 undefined:
// domain/order.ts
import { User } from "./user";
// domain/user.ts
import { Order } from "./order"; // 两个文件互相引用,形成环
典型的运行时错误长这样:
ReferenceError: Cannot access 'User' before initialization
修复方式通常是把共享的部分下沉到第三个文件(例如 domain/shared.ts),让依赖重新变成一条链而不是一个环。
第二种:跨层直连。 控制器里直接 new PrismaClient(),绕过 application 层。它的症状很好认:想写一个单元测试,却发现必须连上数据库。修复方式是补一个端口接口,把实现挪到 infrastructure。
第三种:上帝模块。 一个 utils.ts 越写越大,最后所有人都 import 它,它也开始 import 所有人——于是它既是所有人的依赖,又依赖所有人,环就出现了。判断标准很简单:如果一个文件被 20 个地方引用,它就应该被拆成若干职责单一的文件。
另外还有一个「半类型错误」的坑:导入了一个没有 export 的东西,报错信息如下:
error TS2459: Module './user' declares 'User' locally, but it is not exported.
以及路径写错时的经典报错:
error TS2307: Cannot find module '../domain/user' or its corresponding type declarations.
这两条报错都在提示同一件事:模块边界没有被显式声明。养成在跨层导入时写 import type { ... } 的习惯,可以让只用于类型的导入在编译后被完全擦除,避免不必要的运行时依赖——具体机制在 ES 模块与模块解析
里讲过。
什么时候该拆,什么时候别拆
最后给两条反过来的提醒,避免陷入「为了结构而结构」:
- 拆分的信号:一个文件的 import 列表长到你需要滚动才能找到目标;或者两个文件总是一起被修改。
- 不要拆的信号:两个东西虽然看起来相似,但变化的原因不同(一个跟着数据库改,一个跟着 UI 改),那就应该分开;反过来,如果它们总是因为同一个原因变化,就不该分。
如果你在做的是一个单体项目,还没到微服务的规模,可以看看 模块化单体 这个折中方案;它把上面的分层规则用在一个部署单元里,而不是拆成多个服务。
如果你已经用 monorepo 管理多个包,那么「契约包放哪里」就成了结构问题的一部分,Monorepo 与 Project References 讲了工具层面的配置;而 TypeScript 项目架构与 tsconfig 组织 提供了另一种按 tsconfig 划分边界的具体做法,可以作为本节目录树的补充参考。
小结
这一节我们建立了三件事:
- 结构问题的爆发点在 100 个文件之后,所以要在还能轻松重构的时候就定好骨架,而不是等到第三次有人问「这个函数在哪」。
- 分层的唯一硬规则是依赖单向。domain 不依赖任何人,application 依赖 domain 与自己的端口接口,infrastructure 实现端口,interfaces 面向外部。用表格把这四层的「可以依赖 / 绝不允许依赖」写下来,比任何文档都有效。
- 同一个业务概念在不同层要用不同的类型:DTO 描述网络,领域模型描述业务,视图模型描述界面,中间用显式转换函数连接。
还有一个贯穿全节的结论值得单独强调:好的结构会让 TypeScript 替你发现问题。接口新增方法时内存实现会编译失败,跨层引用会编译失败,导入未导出的名字会编译失败——这些都是免费的安全网,前提是你把边界画对了。
不过,结构画好了只是第一步。上面所有例子里,UserResponseDto 都是在前端和后端各写了一份——这就是全栈项目里最费神的一类类型问题。下一节我们专门解决它:前后端共享类型与 API 契约。
阅读导航:上一节:16.3 Monorepo 与 Project References · 下一节:17.2 前后端共享类型与 API 契约 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。