用“接口加实现”的分层组织后端代码
想解决的问题
后端代码最容易写成的样子是:业务逻辑里直接调数据库、直接调第三方接口。刚开始写得快,后面有两件事会很难受:
- 写单测要连真的数据库和外部服务,又慢又不稳定。
- 想换一个数据库或者换一家服务商,得把业务代码翻一遍。
这种分层的名字叫“端口和适配器”,说白了就是:业务代码只认接口,具体用什么实现,最外面再接上。
目录怎么分
core/
ports/ 接口:要存什么、要查什么、要调什么外部能力
usecases/ 业务规则:只 import ports,不碰任何具体实现
infra/
some-db/ 接口的具体实现:某个数据库、某个云服务
container.ts 把具体实现装进业务规则里,整个程序只有这一处知道用的是谁
一个小例子
// core/ports/record-repo.ts
export interface RecordRepo {
add(userId: string, record: NewRecord): Promise<Record>;
listByDay(userId: string, day: string): Promise<Record[]>;
}
// core/usecases/add-record.ts
export async function addRecord(
deps: { records: RecordRepo; clock: Clock },
userId: string,
input: RecordInput,
): Promise<Record> {
// 校验、计算、业务规则都在这里,看不到任何数据库代码
const record = buildRecord(input, deps.clock.now());
return deps.records.add(userId, record);
}
连时间都做成接口(Clock),是因为“今天是哪天”会影响很多规则。测试里能把时间固定住,跨天、跨月的情况都好测。
带来的好处
-
单测用内存里的假实现:一个用数组存数据的
RecordRepo,几十行就够。测试不连网,几秒钟跑完几百个用例。 -
换底层只换实现:比如从云数据库换到自己的 PostgreSQL,只需要在
infra/下新写一套实现,在container.ts里换一下,业务规则和它的测试一行不动。 - TypeScript 开严格模式以后,新写的实现漏了方法、参数类型对不上,编译时就报错,不用等到运行时。
要注意的
- 接口按业务需要来定,别照着某个数据库的写法定,不然换实现时还是得改接口。
- 假实现的行为要和真实现保持一致,比如排序、找不到时返回什么。可以写一组测试,让真假两种实现都跑一遍。
-
可以加一条代码检查规则,禁止
core/里 importinfra/,防止分层慢慢被破坏。