t3code架构实践:三层分离与依赖约束如何根治代码重构难题
t3code这个代号是我给手头一个业务中台项目起的。t3在工程里一直是对Third Try的缩写——第三次重构尝试code就是代码本身。前两次重构我都失败了具体来说不是项目倒了而是代码在维护半年之后整整齐齐地烂回去。第三版用了一套现在看起来不算惊艳、但切实解决了我所有痛点的架构约束管住了复杂度这版代码的内部代号就是t3code。这套方案并不只有我的业务能用。如果你正被读自己一个月前写的代码像读天书加一个新需求要动六七个文件每次上线最怕改公共模块这类问题折磨那t3code的拆解思路、分层原则和落地手段都能直接抄到你的项目里。我尽量把每一步为什么这么做、踩过哪些坑都写出来而不是只丢给你一堆结论。1. 项目背景t3code到底在解决什么问题1.1 前两次重构为什么会烂掉第一次重构发生在项目上线第八个月。那时候业务方一口气提了十几个需求我一边赶工期一边往原来的单体脚本里塞逻辑最后塞到一台4核8G的服务器跑定时任务都能把CPU吃满。我把锅甩给代码太乱于是轰轰烈烈地重写。结果呢我只是换了一堆新类名、新文件夹底层还是一个函数从头干到尾的思维方式。代码确实漂亮了两周之后新需求一来又变成在十几个文件里来回打补丁。第二次重构我学乖了开始用设计模式工厂、策略、观察者一堆概念往里套结果抽象过头一个简单的查询要走四层封装。性能没救回来同事接手的时候骂了我一个星期。这两次失败让我意识到一个本质问题烂代码不是靠重写治好的是靠约束治好的。你必须有明确的边界让每一行代码都知道自己属于哪一层、该依赖谁、不该碰谁。t3code就是围绕约束两个字重建的工程。1.2 t3code的核心目标拆解到第三版我给t3code定了四个具体目标新增一个查询接口最多只允许改动2个文件。改动超过2个文件就说明分层边界没划对。领域层代码里不允许出现任何框架相关的import。比如express的Request、koa的Context一个都不能出现在service层。所有模块对外暴露的必须是类型和接口而不是具体实现类。这样别人调用你的模块时不需要知道你内部用的是Redis还是MySQL。保证每个纵向切片controller到dao都可以单独测试不需要起完整的服务。这四个目标听起来简单但每一条都在倒逼着架构设计做出改变。t3code里的t3其实还有第二层含义Type、Team、Transformation。类型先行、团队协作边界、以及重构这件事本身的方法论。后面写到的内容基本都围绕这三个词展开。1.3 适合谁来参考这套方案如果你是一个人在写个人项目t3code这套东西可能偏重你不需要养一个完整的分层骨架。但如果你在维护一个三五个人的小团队项目或者你负责的模块未来半年还会持续迭代那这套约束就非常值得借鉴。我这里说的小团队不是大厂那种几十人协作而是那种谁请假了代码就卡住的小组。t3code的分层方式不解决并发协作问题但能让任何一个人接手某个模块时通过读类型定义就知道这个模块能干什么、依赖什么、不依赖什么。对一个流动率高的团队来说这种看类型就能上手的体验比写一百页文档都管用。2. 整体架构设计三层、三原则和依赖方向2.1 为什么是三层而不是六层很多人一谈架构就想到DDD的四层、整洁架构的五层然后被各种名词劝退。t3code回到最基本的三层分离展示层、业务层、数据层。每层干的事情非常纯粹。展示层只负责两件事接收外部请求、把业务层的结果翻译成外部需要的格式。它应该很薄薄到你可以随时换掉底层的Web框架而不影响业务。业务层负责真正的逻辑编排比如下单前要校验库存、计算价格、生成流水号这类规则它是整个系统的核心。数据层负责把业务层要的数据存取动作翻译成具体的技术调用比如SQL查询、Redis读写、HTTP调用。我没有把接口层和模型层单独拆出来原因很简单在真实的业务代码里接口定义往往和模型强绑定硬拆会让文件数量爆炸反而增加了理解成本。三层是最少可行层数少于三层会导致逻辑互相渗透多于三层则让项目结构显得教条。2.2 依赖方向谁可以依赖谁绝对不能反向t3code整个架构能站稳脚跟靠的不是哪一层的代码写得有多漂亮而是一条谁都不能违反的依赖规则依赖只能从外层指向内层。展示层可以依赖业务层业务层可以依赖数据层但数据层绝对不能反向依赖业务层业务层也不能反向import展示层的任何东西。这条规则落地之后直观的好处是你可以单独替换任何一层而不影响其他层。想从Express换到NestJS把展示层整个换掉业务层一个文件都不用动。想从MySQL换成PostgreSQL数据层内部调整上层接口不变业务层毫无感知。我在代码里用eslint-plugin-import配合自定义规则来强制这张依赖图。比如业务层的目录是src/domain数据层是src/infra我就配置一条规则禁止src/infra里的文件importsrc/domain的东西。谁违反CI直接红。2.3 t3三原则类型优先、模块自治、契约先行架构光有分层还不够t3code在写每一段代码前都要过三遍原则。第一遍是类型优先。在写任何实现代码前先把输入输出类型定义好。订单是什么结构创建订单的参数是什么查询订单的返回值是什么类型就是需求文档它比任何注释都准确。第二遍是模块自治。每个业务模块——比如订单模块、用户模块、库存模块——应该自己管理自己的错误定义、自己的配置项、自己的日志标签。它不应该依赖一个公共工具类来感知整个系统发生了什么。这样做是为了避免改一个公共类牵动全局。第三遍是契约先行。模块和外部的约定比如接口路径、消息格式、错误码必须在一开始就定下来实现细节可以后补。这一点在后端和前端并行开发时尤其重要。前端不需要等后端写完只看契约就能mock数据。这套三原则不是我在书上学来的而是两次重构失败后归纳出的教训。第一次是没类型重构时根本不知道哪些函数改了会连带炸掉第二次是模块之间互相借方法牵一发动全身。如果你只能记住t3code的两件事请记住先定义类型、守住依赖方向。3. 核心模块设计与关键代码实现3.1 类型与契约层用可辨识联合类型定义领域对象t3code里最底层、最核心的文件是领域类型定义。我用TypeScript的可辨识联合类型discriminated union而不是Class来定义订单实体这种选择在后续的switch匹配中会带来极大的便利。// src/domain/order/types.ts export type OrderStatus pending | paid | shipped | cancelled; export interface OrderBase { id: string; userId: string; totalAmount: number; createdAt: Date; } export interface PendingOrder extends OrderBase { status: pending; paidAt: null; } export interface PaidOrder extends OrderBase { status: paid; paidAt: Date; paymentId: string; } export interface ShippedOrder extends PaidOrder { status: shipped; trackingNo: string; } export type Order PendingOrder | PaidOrder | ShippedOrder; export interface CreateOrderInput { userId: string; items: Array{ skuId: string; quantity: number }; }为什么要用联合类型而不是一个宽泛的Order接口塞满所有字段因为真实业务里一个待支付的订单根本没有paymentId如果一开始就把所有字段设为可选你写代码时会陷入无休止的undefined判断。用联合类型类型系统直接帮你锁死了状态机。处理订单时TypeScript会强制你分支处理每种状态。配合一个类型守卫你可以在业务层写如下代码// src/domain/order/guards.ts import { Order } from ./types; export function isPaidOrder(order: Order): order is ExtractOrder, { status: paid } { return order.status paid; }这个场景我第一次用的时候真的很感动。以前判断订单是否支付我要写一堆order.paymentId ! null之类的防御性代码。现在只要调用isPaidOrder(order)TypeScript就会在条件判断的后续代码里自动收窄类型——order在if分支内直接获得paymentId、paidAt等字段的完整类型。3.2 服务层业务逻辑的编排与收敛服务层是整个t3code的心脏它负责把领域类型和仓储接口粘合起来。我特意把服务层设计成无状态的函数集合而不是带内部状态的长寿对象。这样每个方法都能单独被调用、单独被测试。// src/domain/order/order-service.ts import { CreateOrderInput, Order } from ./types; import { IOrderRepository } from ./repository; import { IStockService } from ../stock/stock-service; interface OrderServiceDeps { orderRepository: IOrderRepository; stockService: IStockService; } export function createOrderService({ orderRepository, stockService }: OrderServiceDeps) { return { async createOrder(input: CreateOrderInput): PromiseOrder { // 1. 校验库存 const stockOk await stockService.checkStock(input.items); if (!stockOk) { throw new Error(INSUFFICIENT_STOCK); } // 2. 计算金额省略明细计算 const totalAmount input.items.reduce((sum, item) sum item.quantity * 100, 0); // 3. 创建订单 const order await orderRepository.create({ userId: input.userId, items: input.items, totalAmount, }); // 4. 扣减库存失败则补偿具体略 await stockService.deductStock(input.items); return order; }, }; }注意这里的关键设计构造函数注入依赖。我把orderRepository和stockService作为参数传到工厂函数里而没有在服务内部直接new一个仓储实例。这样写测试的时候你只需要传入两个mock对象不需要把整个数据库启动起来。服务层拿到的数据全部是对着接口编程它不认识什么MongoClient或KnexQueryBuilder。这就是我前面说的防腐层思想——业务代码不跟任何具体技术绑死。3.3 数据访问层仓储模式的落地与事务边界数据层在t3code里叫作repository仓储。每一个业务聚合对应一个仓储接口接口定义在业务层具体实现在基础设施层。// src/domain/order/repository.ts —— 接口定义 import { Order, CreateOrderInput } from ./types; export interface IOrderRepository { create(input: CreateOrderInput): PromiseOrder; findById(id: string): PromiseOrder | null; findByUserId(userId: string, page: number, pageSize: number): PromiseOrder[]; }具体实现放到src/infra/repository/order-mysql.ts// src/infra/repository/order-mysql.ts —— 实现 import { IOrderRepository } from ../../domain/order/repository; import { Order, CreateOrderInput } from ../../domain/order/types; import { db } from ./db-client; export class MysqlOrderRepository implements IOrderRepository { async create(input: CreateOrderInput): PromiseOrder { const [result] await db.query( INSERT INTO orders (user_id, total_amount, status, created_at) VALUES (?, ?, pending, NOW()), [input.userId, input.totalAmount] ); const rawOrder await this.findById(result.insertId); if (!rawOrder) throw new Error(ORDER_CREATE_FAILED); return rawOrder; } async findById(id: string): PromiseOrder | null { const rows await db.query(SELECT * FROM orders WHERE id ?, [id]); if (rows.length 0) return null; return this.mapToDomain(rows[0]); } private mapToDomain(row: any): Order { return { id: String(row.id), userId: String(row.user_id), totalAmount: Number(row.total_amount), status: row.status as Order[status], createdAt: new Date(row.created_at), ...(row.paid_at ? { paidAt: new Date(row.paid_at) } : {}), }; } }事务的边界是t3code里最容易踩坑的一个点。我推荐的做法是事务放在服务层开启而不是在数据访问层开启。原因是服务层才清楚多个仓储操作是不是一个完整的事务单元。如果每个repository自己开事务两个仓储之间的一致性根本没法保证。开事务的方式我会在实操章节单独写。3.4 错误处理别把异常堆得到处都是业务系统最忌讳的就是随手throw new Error(something went wrong)。t3code把所有错误收敛为带业务码的结构化错误。// src/shared/error.ts export class AppError extends Error { constructor( public readonly code: string, message: string, public readonly httpStatus: number 500 ) { super(message); this.name AppError; } }使用方式很直接。库存不足、订单不存在、用户无权限全部用这种带code的错误抛出。展示层捕获到时可以直接根据httpStatus返回对应的HTTP状态码根据code往前端传错误标识。日志系统也可以直接按code做告警聚合比全文搜索错误消息靠谱得多。这个设计还有一个隐藏好处跨语言协作时特别好用。后端是个Node服务前端用TypeScript。两边共享一套错误码枚举前端拿到错误之后直接查表展示文案不需要看后端的心情翻源码。4. 从零复刻t3code骨架的实操过程4.1 初始化和目录结构假设你当前是一个Express项目我们开始按t3code的骨架来整理。第一步先把目录结构落好我用的就是这个结构src/ domain/ order/ types.ts guards.ts service.ts repository.ts # 接口定义 stock/ types.ts service.ts repository.ts common/ error.ts application/ order/ create-order.ts # 用例编排optional简单服务可跳过 infra/ repository/ order-mysql.ts stock-mysql.ts web/ routes/order.ts # Express路由 middlewares/error-handler.ts shared/ logger.ts config.ts这里有一个取舍点我把domain和application分开了但层级上application直接依赖domain、infra直接依赖domain。如果你觉得application层太虚可以把它并进domain/service.ts——t3code三层的第三层就是业务层的实现。真实项目中我保留了application层因为它能放一些用例级的编排逻辑比如先查库存、再下订单、最后发消息这种跨领域模块的流程更适合放在用例层而不是某个domain模块内部。4.2 用一个订单查询功能走通整个纵向切片为了让读者真正体会纵向切片的完整流程我带着大家一步一步跑通创建订单这个核心用例。骨架代码我直接用前面已经写好的版本。首先在路由层定义请求入口// src/infra/web/routes/order.ts import { Router } from express; import { createOrderService } from ../../../domain/order/service; import { MysqlOrderRepository } from ../../repository/order-mysql; const router Router(); const orderRepository new MysqlOrderRepository(); const stockService { /* mock */ }; router.post(/orders, async (req, res, next) { try { const service createOrderService({ orderRepository, stockService }); const order await service.createOrder({ userId: req.body.userId, items: req.body.items, }); res.status(201).json({ orderId: order.id, status: order.status }); } catch (err) { next(err); } });接着补上全局的错误处理中间件让所有AppError都能被统一转换成响应// src/infra/web/middlewares/error-handler.ts import { NextFunction, Request, Response } from express; import { AppError } from ../../../shared/error; export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { if (err instanceof AppError) { res.status(err.httpStatus).json({ code: err.code, message: err.message }); return; } console.error([unhandled error], err); res.status(500).json({ code: INTERNAL_ERROR, message: 服务器开小差了 }); }注意这个错误处理器里我用了一个最佳实践未知错误绝不返回原始堆栈信息给客户端只在服务器日志中打印。把细节暴露给外部等于给攻击者递刀子。最后把路由挂到应用主体上// src/index.ts import express from express; import { orderRouter } from ./infra/web/routes/order; import { errorHandler } from ./infra/web/middlewares/error-handler; const app express(); app.use(express.json()); app.use(orderRouter); app.use(errorHandler); app.listen(3000, () { console.log(t3code skeleton running on :3000); });这样一个请求进来 - 路由翻译 - 服务编排 - 仓储落地 - 结果返回的纵向切片就完整了。你会发现路由层只负责翻译不负责写逻辑服务层只负责编排不碰HTTP仓储层只负责数据不关心请求怎么进来的。每层职责单到没办法更清晰。4.3 事务控制在服务层的完整写法这一节单独展开是因为事务边界这个问题我反反复复栽过太多次。刚开始我把事务塞进repository结果一个业务操作要操作三张表的时候就傻眼了——三个repository各管各的事务根本无法保证原子性。在t3code里我引入了一个简单的withTransaction辅助函数整个事务逻辑显式地在服务层控制// src/infra/db/transaction.ts import { db } from ./db-client; export async function withTransactionT( callback: (tx: { query: typeof db.query }) PromiseT ): PromiseT { const conn await db.getConnection(); try { await conn.beginTransaction(); const result await callback({ query: conn.query.bind(conn) }); await conn.commit(); return result; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }在Service里使用async createOrder(input: CreateOrderInput): PromiseOrder { return withTransaction(async (tx) { // 注意这里所有数据库操作都通过tx.query执行 const orderId await tx.query(INSERT INTO orders ...); await tx.query(UPDATE stocks SET quantity quantity - ? WHERE sku_id ?, ...); return this.orderRepository.findById(orderId); }); }为什么要在服务层开事务而不是让repository自己管因为事务的粒度业务用例的粒度。一个用例可能涉及多个聚合只有服务层才能看到完整边界。如果repository自己开事务那些看起来无害的两个update就会悄悄脱离外层事务数据出错时你根本查不出原因。4.4 让Multi层协作的自动化检查跑起来手动遵守架构规范很容易在压力大的时候失守。t3code的CI里加了三条自动检查确保架构约束持续有效。第一条eslint-plugin-import的no-restricted-paths。配置如下{ rules: { import/no-restricted-paths: [error, { zones: [{ target: ./src/infra, from: ./src/domain, message: infra不能依赖domain外的实现domain的接口定义必须保持纯净 }] }] } }第二条类型检查必须过tsc --noEmit。我把这个检查合并进pre-commit的hook里谁要是写了个类型错误的代码根本走不到Code Review那一步。第三条引入dependency-cruiser生成依赖图并校验规则。它能可视化地画出模块间的依赖关系CI里只要出现反向依赖就直接飘红。这套检查在项目里跑了半年真的拦住了两三次随手import了业务层方法的低级错误。5. 踩坑记录重构中常见的5个问题与解决办法5.1 循环依赖怎么看都不该发生但就是发生了t3code把业务拆成订单、库存、用户三个模块按理说它们应该各管各的。但开发到第三周我突然发现orderServiceimport了stockService而stockService又import了orderService来查询这个订单是否占用了库存。端口形成了一个环。我把两个模块共用的逻辑抠出来下沉到domain/common里而不是让两个模块互相引用。具体到场景里我把订单占用库存记录这个实体独立成一个领域事件而不是直接跨模块调方法。这种抠公共逻辑下沉的思路比强行改接口要优雅得多。5.2 领域层被框架类型污染你说好领域层不允许出现框架代码但开发到中期我在orderService里写了一个函数的参数类型是Express.Request只因为想方便地取req.user.id。这就是典型的类型泄漏。违背了领域层不感知HTTP的原则导致整个模块跟Express绑死单元测试也要费劲去构造一个完整的请求对象。解决办法只有一个在展示层解析HTTP请求只把真正需要的userId、items这类业务参数传给服务层。领域层的函数签名只认业务类型不认识任何框架类型。5.3 事务里嵌了外部HTTP调用有一版代码服务层在事务里调用了一个第三方物流接口结果第三方接口超时整个数据库事务被长时间挂起。阻塞若一长连接池直接耗尽。这个问题的根治办法很简单不要在事务里做外部IO。事务只包裹数据库操作。比如创建订单这个用例先把订单落库、扣库存事务提交之后再把发送通知放到事务外异步执行。我把这个原则写进了团队规范并且用Code Review时盯住事务内有没有await非DDL/DML调用。5.4 测试困难依赖不注入导致难mock确实写createOrder这个服务测试时如果你在服务内部直接new MysqlOrderRepository()测试就跑不起来。依赖注入在前面的代码里已经体现得很清楚了这里再强调一下在测试中如何用最简单的mock覆盖依赖。// tests/unit/order-service.test.ts import { createOrderService } from ../../src/domain/order/service; test(创建订单时库存不足应该抛出INSUFFICIENT_STOCK错误, async () { const orderRepository { create: jest.fn().mockResolvedValue({ id: 1, status: pending }), findById: jest.fn(), }; const stockService { checkStock: jest.fn().mockResolvedValue(false), deductStock: jest.fn(), }; const service createOrderService({ orderRepository, stockService }); await expect(service.createOrder({ userId: 1, items: [] })).rejects.toThrow( INSUFFICIENT_STOCK ); expect(stockService.checkStock).toHaveBeenCalled(); });这个测试没有启动任何服务、没有连接任何数据库跑起来毫秒级完成。这也是t3code的目标之一每个纵向切片的单元测试必须能在5秒内跑完。如果做不到说明你的服务层耦合了太多基础设施。5.5 性能回退分层能不能做到不拖垮性能多层抽象一定会带来一点性能开销。这不是t3code独有的问题所有分层架构都有。我在实际项目中踩到的性能坑主要是N1查询。比如查订单列表我先查了20条订单接着循环每条订单查一次用户信息20条订单就多了20次查询。解决这个问题的标准做法是批量加载。在仓储接口里提供一个findByIds(ids: string[])方法服务层调用一次拿到这20个用户的信息然后自己在内存里做关联。另一个容易忽略的性能点是别在循环里造服务实例。我见过有人在forEach循环里创建createOrderService()一次订单列表查询就new了几十个实例。t3code的服务工厂适合在路由初始化时创建然后在整个请求链路中复用。6. 写在重构完成之后t3code真正教会我的东西不是某个框架或某种代码组织方式而是**约束比自由更能带来效率**。前两次重构我追求的是代码的自由——想怎么拆就怎么拆想用哪个模式就用哪个模式。结果是半年后代码烂成一锅粥。第三版强制分层、强制类型、强制接口依赖大家反而觉得轻松了因为没人需要在烂泥潭里去猜一个函数到底是干嘛的。我在实际运行t3code的这段时间里最大的收获是写代码最值钱的动作其实是删除和重命名而不是新增。当依赖方向清晰时你会发现有很多公共函数根本不配存在很多业务类型其实可以合并。每次重构代码不要急着写新逻辑先花一天时间把类型梳理干净让编译器替你查出所有不合适的依赖。如果让我给一个实操性的建议那就是从今天开始把你项目里最核心的3个业务模块用t3code的方式重新梳理一遍先定义类型再定义事务边界最后收紧依赖。你不需要一次性推翻全项目只需要一个模块一个模块地迁移。大概两周之后你会明显感觉到改代码比以前有底气多了——那种改一个字段不知道哪里会炸的恐惧会被类型系统和依赖检查牢牢锁住。这个架构后续还可以往事件驱动、CQRS这些方向扩展但那是后话。先把基础约束立起来比什么高端设计都管用。