《TypeScript编程入门》17.1 项目结构与分层设计

本节把前面散落的语言知识收拢成一个能交付的项目骨架:先对比按类型、按功能、按层三种组织方式的代价,再讲清分层的本质是依赖方向单向,并用一棵完整目录树演示 domain、application、infrastructure、interfaces 各层怎么摆。随后区分 DTO、领域模型与视图模型三类类型,用接口把依赖倒置落地,最后给出循环依赖、跨层直连、上帝模块三种腐化信号的识别与修复。

本节目标:读完这一节,你能说清「按类型拆」和「按功能拆」各自的代价;能画出一个前后端项目里各层之间的依赖箭头并保证它单向;能把 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 层

为什么值得为这条规则付出「多写几层文件」的代价?两个收益:

  1. 可测试性。用例只依赖接口,测试时注入一个内存实现就能跑,不需要数据库和 HTTP 服务。
  2. 可替换性。把 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 划分边界的具体做法,可以作为本节目录树的补充参考。

小结

这一节我们建立了三件事:

  1. 结构问题的爆发点在 100 个文件之后,所以要在还能轻松重构的时候就定好骨架,而不是等到第三次有人问「这个函数在哪」。
  2. 分层的唯一硬规则是依赖单向。domain 不依赖任何人,application 依赖 domain 与自己的端口接口,infrastructure 实现端口,interfaces 面向外部。用表格把这四层的「可以依赖 / 绝不允许依赖」写下来,比任何文档都有效。
  3. 同一个业务概念在不同层要用不同的类型:DTO 描述网络,领域模型描述业务,视图模型描述界面,中间用显式转换函数连接。

还有一个贯穿全节的结论值得单独强调:好的结构会让 TypeScript 替你发现问题。接口新增方法时内存实现会编译失败,跨层引用会编译失败,导入未导出的名字会编译失败——这些都是免费的安全网,前提是你把边界画对了。

不过,结构画好了只是第一步。上面所有例子里,UserResponseDto 都是在前端和后端各写了一份——这就是全栈项目里最费神的一类类型问题。下一节我们专门解决它:前后端共享类型与 API 契约。

阅读导航:上一节:16.3 Monorepo 与 Project References · 下一节:17.2 前后端共享类型与 API 契约 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes