8.2 整洁架构与依赖注入


8.2 整洁架构与依赖注入

这节在地图里是架构的"大尺度"落点。前面讲的类型都是点状的,这一节用"依赖倒置"把类型变成层与层之间的边界守门人:核心业务不依赖框架,框架依赖核心定义的接口。TS 的 interface 在这里不是语法糖,而是物理隔离的契约。

整洁架构的分层与依赖方向

核心法则是:源码依赖只能指向内部。领域层不知道数据库、不知道 HTTP、不知道 React。

// 核心层:定义端口(接口),不引任何框架 export interface UserRepository { findById(id: number): Promise<User | null>; save(user: User): Promise<void>; } export interface User { id: number; name: string; } // 应用层:用端口,不关心谁实现 export async function getUser( repo: UserRepository, id: number ): Promise<User | null> { return repo.findById(id); }

输入输出:getUser 只依赖 UserRepository 接口,传入任何实现都能跑。核心层零框架依赖,可单独测试、可换框架。

外层适配器:实现端口

// 外层:用具体数据库实现端口 import { PrismaClient } from "@prisma/client"; import { UserRepository, User } from "core"; export class PrismaUserRepo implements UserRepository { constructor(private db: PrismaClient) {} async findById(id: number): Promise<User | null> { const row = await this.db.user.findUnique({ where: { id } }); return row ? { id: row.id, name: row.name } : null; } async save(user: User): Promise<void> { await this.db.user.upsert({ where: { id: user.id }, create: user as any, update: user as any }); } }

背景:核心不想绑定 Prisma。操作:外层 implements UserRepository。结果:换数据库只换适配器,核心不动。解读:implements 把"适配器满足端口契约"在编译期钉死,适配器写错字段立即报错。

依赖注入:在边界组装

依赖不在核心里 new,而在最外层组装后传入——这就是 DI 的本质。

// 组装层(如 main.ts),唯一知道所有具体类的地方 import { PrismaClient } from "@prisma/client"; import { PrismaUserRepo } from "adapters"; import { getUser } from "core"; const repo = new PrismaUserRepo(new PrismaClient()); getUser(repo, 1).then((u) => console.log(u));

这张图表达依赖方向(箭头指向内部):

08-05-fig01

实战:用 DI 让核心可测

// 测试时传入假实现,不碰数据库 class FakeRepo implements UserRepository { async findById() { return { id: 1, name: "测试" }; } async save() {} } await getUser(new FakeRepo(), 1); // 纯内存测试

背景:不想为单测起数据库。操作:注入假仓库。结果:核心逻辑纯函数式可测。解读:类型让"假实现"必须真满足接口,测试替身不会悄悄漏字段。

工程取舍:别为小项目上全套

整洁架构的代价是间接层多、样板多。单人小项目不必严格分层,直接在 handler 里查库也行。当项目出现"核心逻辑要在多端复用""框架可能替换""测试要快且隔离"时,分层才回本。类型接口不是越多越好,是"该隔离的边界"才用接口守。

实战:用泛型仓储消除重复接口

多个实体都有"增删查",用泛型 Repository<T> 把重复接口收成一个,又不丢类型安全。

interface Repository<T, ID = number> { findById(id: ID): Promise<T | null>; save(entity: T): Promise<void>; all(): Promise<T[]>; } interface User { id: number; name: string; } interface Order { id: number; amount: number; } class UserRepo implements Repository<User> { async findById(id: number) { return { id, name: "x" }; } async save() {} async all() { return []; } }

输入输出:UserRepo 实现 Repository<User> 后,findById 的返回被约束为 Promise<User | null>save 的参数必须是 User。多个实体各实现一份泛型仓储,既免去重复写"增删查"签名,又各自保类型。解读:泛型(第三章)在此把"架构端口"模板化,是整洁架构里最常用的去样板手段。

实战:用类型守卫隔离外层数据

外层适配器从数据库拿到的是"未知形状",进入核心前用类型守卫验证,避免脏数据污染领域层。

function isUser(row: unknown): row is User { return typeof row === "object" && row !== null && "id" in row && "name" in row; } function toUser(row: unknown): User { if (!isUser(row)) throw new Error("数据不符合 User 契约"); return row; // 此处 row 已被收窄为 User }

背景:ORM/原生查询返回 anyunknown,直接当 User 用会丢掉保护。操作:在适配器边界写 isUser 守卫。结果:只有通过验证的数据才进核心,且核心内 row 是精确 User。解读:守卫是"运行时校验"与"编译期类型"的桥(第二章、第七章),在架构边界处尤其关键——它是外部不可信数据与内部契约之间的闸门。

Result 类型替异常,把错误写进签名

核心层常跨边界(数据库、网络),用异常抛出则错误"隐在控制流外",调用方易漏 try/catch。用可辨识联合把"成功/失败"显式写进返回类型。

type Result<T, E = string> = | { ok: true; value: T } | { ok: false; error: E }; // 核心用例返回 Result,而不是 throw async function getUserSafe(repo: UserRepository, id: number): Promise<Result<User>> { const u = await repo.findById(id); return u ? { ok: true, value: u } : { ok: false, error: "用户不存在" }; } // 调用方被迫处理错误分支,漏了编译期就标红 const r = await getUserSafe(repo, 1); if (r.ok) console.log(r.value.name); else console.log(r.error);

背景:异常在跨层时被吞或漏接是常见 bug。操作:用例返回 Result<T> 联合。结果:错误成为"必须处理"的契约,而非"可能崩溃"的隐忧。解读:这和 API 响应联合(第七章)、穷尽检查(8.1)同源——把"可能失败"显式化,是类型即契约在架构层的延伸。

用例层的输入/输出类型化

应用层用例(use case)把"入参"和"出参"都定义为端口类型,外层只负责转换边界格式。

// 应用层:用例输入/输出都是明确类型 interface GetUserInput { id: number; } interface GetUserOutput { id: number; name: string; } async function getUserCase( repo: UserRepository, input: GetUserInput ): Promise<Result<GetUserOutput>> { const u = await repo.findById(input.id); return u ? { ok: true, value: { id: u.id, name: u.name } } : { ok: false, error: "无" }; } // 外层把 HTTP 请求体转成 GetUserInput,再调用例

背景:用例若直接吃 req.body(任何 JSON),契约就漏到边界。操作:用例只认 GetUserInput。结果:边界转换集中在外层,用例纯净可测。解读:把"变形"压在边界、把"契约"留在内部,是整洁架构的类型化纪律——和 7.2 "边界用 zod 校验"呼应,只是这里处理的是内部用例边界。

端口粒度的取舍

端口(interface)太细,每个方法一个接口,样板爆炸;太粗,一个巨型接口塞所有能力,换实现时被迫实现一堆无关的。折中:按"用例视角"组织端口,一个能力族一个接口。

// 偏细:换实现累赘 interface FindUser { find(id: number): Promise<User | null>; } interface SaveUser { save(u: User): Promise<void>; } // 偏粗:无关方法被迫实现 interface BigRepo { find(): ...; sendMail(): ...; log(): ...; } // 折中:按业务能力族 interface UserRepository { findById(id: number): Promise<User|null>; save(u: User): Promise<void>; }

背景:端口粒度没标准答案。操作:以"会被一起替换/复用"为界组织。结果:实现方只背负相关方法。解读:粒度本质是"变化频率"的分组——常一起变的放一个接口,这正是依赖倒置想达成的"稳定边界"。

本节要点回顾

  • 依赖只能指向内部,核心层零框架依赖
  • 端口用 interface 定义,适配器 implements
  • DI 在最外层组装,核心不 new 具体类
  • 小项目别过度分层,有复用/可测/可换需求才上

⚠️ 别以为"用了 interface 就是整洁架构"。若核心层还 import 了框架类型或具体类,依赖方向就破了,分层只是表面文章;看依赖箭头是否真的只指向内。

💡 真正隔离的边界才值得用接口守门。先问"这块会不会被换实现或被单测替身",会才抽端口,否则直接写实现更省事,别为架构而架构。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U