☰
t3code实战:三层架构如何根治Service层混乱与代码维护难题
2026/10/27 23:18:07 网站建设 项目流程

前阵子接手一个商家后台项目,代码量不算大,两万来行,但维护成本已经高得吓人。改一个下单流程要翻遍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(Map<String, Object> conditions)来完成,结果完全失去语义。

t3code推荐的是仓储模式(Repository Pattern)的轻量变体:一个实体一个仓储接口,接口方法名必须描述业务意图。比如:

public interface OrderRepository { OrderEntity findById(Long orderId); List<OrderEntity> findPendingTimeoutOrders(); int updateStatusAndPaymentInfo(OrderEntity order); }

这个方法名字本身就是可读的业务语言,上层调用时不用猜SQL是什么。实现内部可以既有MyBatis的Mapper,也有JPA的Repository,甚至混用Redis缓存加速。只要接口不膨胀,数据层实现怎么换都对上层透明。

关于ORM的使用边界,我个人的建议是:单表常规CRUD用ORM,复杂查询手写SQL映射到明确的结果对象,禁止把N+1查询的锅甩给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返回OrderBizModel,Controller交给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代码膨胀业务逻辑上移下沉到Service,Controller只做翻译
Service里出现SQL数据访问漏到业务层封装进Repository接口
事务不生效同类自调用或异常被吞拆类调用或统一异常抛出
循环依赖模块间直接互相调用服务下沉数据层或接口化松耦合
Repository方法膨胀缺少设计,乱加特化接口评审接口语义,组合已有接口
DTO穿透业务层数据结构和业务耦合使用Assembler转换,定义BizModel

4.5 几件值得记住的“反直觉”经验

第一件,分层会多写代码,但能少写加班。很多人抵触t3code是因为要写Assembler和DTO转换,觉得绕。但从改需求的总体工作量看,边界清晰后定位问题、回归测试的时间大幅下降。多写的那几十行转换代码,换来的是后续修改时不用猜依赖关系。

第二件,不要一上来就全局重构。我见过团队大张旗鼓推分层架构,结果一个月没发版,老板在群里问“在忙什么”。分层改造适合顺着迭代逐步推进,新模块直接按t3code落地,旧模块每次动刀都顺手往目标结构靠拢。平滑过渡,比一次性推翻重构稳妥得多。

第三件,评审纪律比架构设计更重要。t3code不是什么复杂技术,它的脆弱点在于执行层面。如果团队评审走过场,任何一套架构都会被腐蚀。我在实际操作中只要看到一次跨层调用没有被打回,第二天就会出现三次,下个月就会到处都是。所以宁可评审慢一点,也不要放过第一次破坏规则的人。

第四件,工具辅助很重要。项目里可以配置架构测试(ArchUnit这类方案),在单元测试阶段自动检查依赖方向、包访问规则。人盯人是盯不过来的,让测试在CI阶段把违规代码拦下来,比事后口头批评有效得多。

这些经验是我在多个项目里反复试错之后沉淀下来的。t3code不是银弹,它解决的是“代码组织混乱、修改成本失控”这一类问题,如果你遇到的是性能瓶颈、分布式一致性之类的挑战,这套方法帮不上太多忙。但如果你正在为Service层越写越肥、Controller职责越来越杂而头疼,不妨从目录结构调整和依赖方向约束开始,先把边界立起来,后面的事情会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询