t3code实战:三层架构如何根治Service层混乱与代码维护难题
前阵子接手一个商家后台项目代码量不算大两万来行但维护成本已经高得吓人。改一个下单流程要翻遍Controller、Service、Util三层文件改完还不敢保证会不会连带炸掉别的模块。那时候团队里就有人提议不如用t3code的方式彻底重新组织一遍。所谓t3code其实就是以三层架构Three-tier Architecture为核心的一套代码落地方法论把表现层、业务层、数据层的边界用工程手段固化下来而不是停留在PPT上的架构图。这套方法不是新理论但真正执行到位、能让团队每个人不乱穿层的项目确实不多。这篇文章我就把t3code从思路拆解到目录设计、从链路实现到坑点排查完整梳理一遍。适合正在做中后台系统、微服务拆分或者被乱七八糟的Service层折磨到崩溃的开发者和技术负责人参考。不管你用的是Java、TypeScript还是Go核心思想通用落地细节我会尽量展开讲清楚。1. t3code 到底是什么三层架构的代码落地思路1.1 从“代码能跑”到“代码可维护”的分界线绝大多数项目的第一个版本都能跑但三个月后还能不能愉快地改需求就是另一回事了。我见过太多项目初期堆功能很快后期加需求很慢改一个接口要顺着调用链把十几个文件全看一遍加一个字段要同时改实体、DTO、VO、数据库表、缓存Key。问题不在某个人的代码水平而在整个项目的分层边界没有立住。t3code的核心主张很朴素把系统清晰地分成表现层、业务逻辑层、数据访问层每层只做自己该做的事通过接口通信不允许跨层乱调用。听起来像软件工程教科书里的概念但真正落地的关键是从目录结构、依赖方向、命名规范、代码评审四个维度同时约束缺一个都会回归混乱。这套东西解决的核心矛盾是业务复杂度与代码可维护性之间的冲突。业务变了改动应该集中在业务层接口变了改动应该集中在表现层表结构变了改动应该集中在数据层。如果你每次改动都要同时碰这三层那不是需求复杂是分层失效了。1.2 t3code 的三层分工表现层、业务层、数据层先明确一下t3code里每一层的职责边界这个边界画得越清楚后面写代码越不用动脑子。**表现层Interface Layer**负责两件事接收外部输入、格式化外部输出。参数校验可以做权限校验的入口可以放但坚决不写业务规则。比如“订单金额超过500需要走审批”这句话如果出现在Controller里层就已经脏了。表现层应该像前台接待客人来了问清楚需求、登记来访信息但具体公司内部的流程怎么走不归前台管。**业务层Business Layer**是系统的核心承载所有业务规则、流程编排、事务控制。订单怎么创建、库存怎么扣减、优惠券怎么核销这些决策全部落在业务层。业务层不知道调用方是HTTP接口、RPC还是消息队列也不关心数据最终存在MySQL还是Redis它只面向业务本身。**数据层Data Layer**负责持久化和数据访问。表结构映射、SQL编写、Redis读写、ES查询都收敛在这一层。对外暴露的是符合业务语义的接口而不是“执行了某条SQL”的裸操作。比如数据层提供的是getAvailableInventory(skuId)而不是select * from inventory where sku_id ? and status 1。1.3 为什么不是 MVC 或六边形架构有人会问MVC和六边形架构不也能解决问题吗确实能但t3code在团队落地时有明显的优势理解成本低、约束成本低、适配大多数业务系统。MVC的问题在于Model的概念太宽泛。业务逻辑到底放在Service还是Model里ORM实体算不算Model数据校验算哪一层每个人理解不同写出来的代码风格就不同。t3code直接把Model这个暧昧的概念拆掉实体、业务对象、DTO各归其位边界一目了然。六边形架构端口与适配器在理论层面更优雅但对大多数业务团队来说过于抽象。你要解释什么是Driver Port、什么是Driven Port还要让团队接受依赖反转的各种花活。t3code选择的是更务实的单向依赖表现层依赖业务层业务层依赖数据层数据层谁也不依赖。听起来不那么“高级”但每个程序员都能秒懂评审时也方便执行。2. 目录结构与工程规范t3code 的第一步落地2.1 一套可以复制到任何项目的目录结构t3code不是框架不需要引入任何依赖它首先是目录和依赖规则的落地。以下是我实际用下来比较顺手的结构以Java Spring Boot为例换到别的语言思路一样com.example.project ├── interface # 表现层 │ ├── controller │ ├── dto │ └── assembler ├── business # 业务层 │ ├── service │ ├── domain │ └── model └── datasource # 数据层 ├── mapper ├── entity └── repository每个一级目录对应一个层层之间只能向下依赖interface可以依赖business和datasource的接口定义business只能依赖datasource的接口datasource不依赖任何上层。也就说你在business里绝对看不到XXXController在datasource里绝对看不到XXXService。有人会问DTO放在interface/dto里业务层也需要用怎么办答案是业务层定义自己的业务对象表现层通过Assembler做转换不要让DTO穿透到业务层。这确实会多写一点转换代码但换来的是表现层怎么变都不会污染业务层。微信支付接口返回结构变了改的只是表现层的解析逻辑核心交易流程一行不用动。2.2 依赖方向与红线最容易被突破的边界分层架构失效的第一个信号就是依赖方向失控。最常见的违规行为有四种表现层直接调用数据层Mapper绕过业务层。业务层直接操作HttpServletRequest、HttpServletResponse。业务层在代码里写SQL操作数据库表。数据层反向依赖业务层或者项目里出现循环依赖。前三条靠代码评审就可以大部分拦截第四条循环依赖会在编译期或启动期爆出来反而容易发现。真正的难点在于很多违规调用当时看是“捷径”后面看是“技术债”。比如下单接口里顺手查一下用户积分图省事直接调了Mapper等积分规则改了这个下单接口就莫名其妙地出bug了。我后来定了一条硬规矩任何跨层调用必须走接口且只能在相邻层之间发生。表现层不认识数据层想查数据必须先经过业务层的接口。起初团队觉得麻烦适应两周之后反而觉得清爽因为出问题时排查路径非常短。2.3 命名规范与模块边界让每层自带“辨识度”命名规范在t3code里不是洁癖而是降低沟通成本的工具。拿到一个类名马上能判断它属于哪一层、承担什么职责这比看注释高效得多。我比较推荐的命名前缀规则表现层Controller、DTO、VO、Assembler、Facade。业务层Service、DomainService、EventHandler、BizModel。数据层Mapper、Repository、DAO、Entity、PO。以订单模块为例OrderController接收HTTP请求OrderVO是给前端的响应结构OrderCreateCommand是下单命令对象OrderService负责下单流程OrderEntity对应数据库表OrderRepository封装数据访问。模块边界则是按业务域而不是按技术分层来划分目录的比如order、user、payment各成一个模块模块内部再按t3code的层级组织。这样既能享受分层带来的依赖清晰又能避免所有业务代码堆在同一个Service类里变成大泥球。3. 核心环节实现从接口定义到数据链路打通3.1 表现层的正确姿势只翻译不决策表现层最容易犯的错是把业务判断写在Controller的if里。我举一个实际踩过的例子订单取消接口原来在Controller里直接判断订单状态如果订单已支付就只能走退款流程。后来支付渠道增加了新的状态Controller的if越写越复杂最后干脆整个业务逻辑都被迫搬到了控制器里。从那个时候起我要求所有人把“表现层不写if”当作硬性纪律除了参数为空这类校验外一切业务判断必须下沉到Service。Controller里的代码应该短到只要三步接收参数、调用服务、格式化响应。接收参数时推荐用Command对象而不是散落的多个参数。方法签名一旦变成createOrder(userId, skuId, count, addressId, couponId, remark)维护性就急剧下降。把参数收拢成CreateOrderCommand新增字段时只改Command和调用方Controller和Service的签名都不受影响。响应格式化方面统一使用Result包装结构但生成动作交给Assembler。Assembler负责把业务层返回的OrderBizModel转换成OrderVO避免前端字段变化反向污染业务层。3.2 业务层硬核拆解用例驱动、事务边界与领域状态业务层是t3code里信息密度最高的部分也是最难做对的部分。我的经验是按“用例”划分方法而不是按“数据库操作”划分方法。每一个Service方法对应一个完整的业务动作下单、取消、退款、确认收货。方法内部可以有多个数据访问操作但对外只呈现一个业务结果。事务边界必须开在业务层这是最核心的纪律。三条原则一个用例一个事务不要在一个事务里做无关操作。跨表、跨模块操作时统一由入口Service管理事务。数据层方法不能自带事务传播配置交给业务层统一控制。我还习惯在业务层引入领域状态机来解决复杂业务流转。订单状态从UNPAID到PAID到SHIPPED到COMPLETED每一步只允许合法迁移非法迁移直接抛异常。这样写出来的代码不需要注释解释状态机本身就是文档。业务层的模型设计同样重要。数据库实体直接用于CRUD没问题但涉及多表数据组装、复杂运算时应该定义业务对象。举个例子订单汇总金额包含商品金额、运费、优惠分摊、税率数据层返回的实体字段是拆分存储的业务层用OrderBizModel聚合后再传给表现层。3.3 数据层的关键取舍仓储模式与ORM的边界数据层最容易出现的两种极端一种是把整张表的所有字段都暴露给上层上层想查什么就查什么另一种是把数据层接口设计得过分通用所有查询都通过一个queryByConditions(MapString, Object conditions)来完成结果完全失去语义。t3code推荐的是仓储模式Repository Pattern的轻量变体一个实体一个仓储接口接口方法名必须描述业务意图。比如public interface OrderRepository { OrderEntity findById(Long orderId); ListOrderEntity findPendingTimeoutOrders(); int updateStatusAndPaymentInfo(OrderEntity order); }这个方法名字本身就是可读的业务语言上层调用时不用猜SQL是什么。实现内部可以既有MyBatis的Mapper也有JPA的Repository甚至混用Redis缓存加速。只要接口不膨胀数据层实现怎么换都对上层透明。关于ORM的使用边界我个人的建议是单表常规CRUD用ORM复杂查询手写SQL映射到明确的结果对象禁止把N1查询的锅甩给ORM。写SQL本身不可怕可怕的是写了SQL还不封装意图直接暴露表名和字段给业务层。3.4 一个完整请求的生命周期演示以“用户取消订单”为例展示t3code下的一次完整请求穿透。前端调用POST /api/orders/{id}/cancel请求进入OrderController的cancel方法。Controller先简单校验参数然后组装CancelOrderCommand调用OrderService.cancel(command)。表现层不关心这个命令接下来要查什么表、改什么状态。OrderService收到命令后先通过OrderRepository.findById(orderId)查出订单实体然后用状态机判断当前状态是否允许取消如果订单已发货抛业务异常如果已支付需要协调退款。此时Service调用PaymentService.refund(...)完成退款同时调用StockService.rollback(...)进行库存回滚最后更新订单状态。整个过程只有一个事务任何一步失败都整体回滚。数据访问全部通过OrderRepository完成查订单、改状态、记录操作日志。仓储内部可能分别操作订单表、订单日志表但上层感知不到。最后Service返回OrderBizModelController交给OrderAssembler转换为OrderVO拼装Result结构返回前端。整个链路里每一层只接触自己该接触的东西表现层看不到SQL和事务数据层看不到HTTP参数和前端字段。这也就是t3code解耦的核心价值。4. 常见问题与排查技巧实录4.1 业务逻辑悄悄跑进 Controller这个存活周期最长的问题即使定了红线也挡不住。典型特征Controller方法超过20行局部变量里有各种金额计算、状态判断。原因不外乎两种情况一是历史代码遗留二是紧急需求下“先跑通再说”。我的处理办法分两步首先做代码评审强校验凡是Controller里出现if判断业务逻辑的一律打回重构其次如果项目已经混乱以模块为单位逐步迁移。先挑低频模块试点把Service方法补全再改Controller调用测试通过后慢慢扩大范围。4.2 跨层调用导致循环依赖Spring Boot项目里最常见的循环依赖就是OrderService需要调用UserService而UserService又调用了OrderService。短期能启动但代码已经接近蜘蛛网。根治思路是拆依赖而不是调顺序。比如订单和用户之间互相要查数据应该把共同的查询下沉到数据层或者把跨模块调用抽象成独立接口。我更推荐模块化拆分订单模块只依赖用户模块的接口定义用户模块同样依赖订单模块的接口定义但实现都不直接依赖对方模块的服务实现。如果短期无法重构完成暂时用Lazy延迟加载兜底是可行的但这只是止损不能长期依赖。4.3 事务失效方法内调用与异常被吞业务层方法事务失效有两个高频原因。第一个是同类内部调用OrderService.cancel()内部调用了同一个类里的updateStatus()方法通过this.调用不会触发代理事务注解直接失效。解决方式很简单把内部方法拆到独立的事务支撑类或者通过注入自己的代理对象调用。但从设计角度看这类问题暴露的是方法拆分不合理不是技术问题。第二个原因是异常被吞。业务层捕获所有异常后手动处理事务感知不到异常自然不回滚。后来我要求事务方法内禁止裸捕获异常只能捕获业务自己关心的场景其余一律抛出由框架统一处理。顺带一个排查技巧看日志里的事务是否生效最直接的办法是在一个测试模块里故意抛异常看数据是否回滚比翻配置快得多。4.4 数据层接口失控与命名混乱数据层接口膨胀是另一个常见问题。今天加一个查询明天加一个修改两个月后OrderRepository上百个方法完全退回DAO模式。我用来控制膨胀的办法是写方法前先看有没有语义重复的接口。很多新查询其实可以通过已有接口组合出来需要警惕的是那些为了“少写几行代码”而不断增加的特化接口。真正必要的特化接口命名必须说清楚业务含义禁止出现findByStatusAndCreateTimeAndUserIdAndPayType这种一长串的裸查询。整理一张排查速查表方便开发时对照表现可能原因解决建议Controller代码膨胀业务逻辑上移下沉到ServiceController只做翻译Service里出现SQL数据访问漏到业务层封装进Repository接口事务不生效同类自调用或异常被吞拆类调用或统一异常抛出循环依赖模块间直接互相调用服务下沉数据层或接口化松耦合Repository方法膨胀缺少设计乱加特化接口评审接口语义组合已有接口DTO穿透业务层数据结构和业务耦合使用Assembler转换定义BizModel4.5 几件值得记住的“反直觉”经验第一件分层会多写代码但能少写加班。很多人抵触t3code是因为要写Assembler和DTO转换觉得绕。但从改需求的总体工作量看边界清晰后定位问题、回归测试的时间大幅下降。多写的那几十行转换代码换来的是后续修改时不用猜依赖关系。第二件不要一上来就全局重构。我见过团队大张旗鼓推分层架构结果一个月没发版老板在群里问“在忙什么”。分层改造适合顺着迭代逐步推进新模块直接按t3code落地旧模块每次动刀都顺手往目标结构靠拢。平滑过渡比一次性推翻重构稳妥得多。第三件评审纪律比架构设计更重要。t3code不是什么复杂技术它的脆弱点在于执行层面。如果团队评审走过场任何一套架构都会被腐蚀。我在实际操作中只要看到一次跨层调用没有被打回第二天就会出现三次下个月就会到处都是。所以宁可评审慢一点也不要放过第一次破坏规则的人。第四件工具辅助很重要。项目里可以配置架构测试ArchUnit这类方案在单元测试阶段自动检查依赖方向、包访问规则。人盯人是盯不过来的让测试在CI阶段把违规代码拦下来比事后口头批评有效得多。这些经验是我在多个项目里反复试错之后沉淀下来的。t3code不是银弹它解决的是“代码组织混乱、修改成本失控”这一类问题如果你遇到的是性能瓶颈、分布式一致性之类的挑战这套方法帮不上太多忙。但如果你正在为Service层越写越肥、Controller职责越来越杂而头疼不妨从目录结构调整和依赖方向约束开始先把边界立起来后面的事情会顺很多。