代码又堆起来了。你打开那个叫OrderServiceImpl的类,滑动鼠标滚轮,四千行,从createOrder到cancelOrder,中间夹杂着validateUser,checkStock,sendNotification, 还有一段注释写着“TODO: 这段逻辑以后要优化”的垃圾代码。你很清楚,这不是一个人的错,是长期在Controller里写校验、在Service里堆业务、在Utils里塞私活的结果。结构清晰不是天赋,是反人性的自律,因为偷懒很容易,而让代码保持可读性需要持续对抗熵增。
我们得承认,SpringBoot的快速开发模式是熵增的加速器。自动配置、依赖注入、CRUD Repository,让一个刚毕业的实习生也能在一周内把接口跑通。但跑通和写好之间,隔着一条由“职责边界”和“依赖方向”划出的鸿沟。很多人以为分了Controller、Service、Mapper三层就叫清晰,实际上只是把垃圾从厨房搬到了卧室——分层是起点,不是终点。
那个被误解的“三层架构”
SpringBoot官方教程里给的模板就是Controller-Service-Mapper,于是大多数人把这当成宗教。商品查库存,Controller里先起一个Result包装,然后调Service,Service里@Transactional,然后调Mapper。看起来没错。但当业务复杂起来,你就发现Service层开始吞噬一切:参数校验、状态流转、短信推送、消息队列、权限判断、甚至是Excel导出。它变成了一个无所不包的“万能中间层”,你的业务逻辑并不在某一个明确的地方,而是像雾一样弥漫在整个Service类里。
为什么会这样?因为三层架构只定义了“分层”,没定义“层内如何组织”。更关键的是,它默认了业务逻辑就天然归属于Service。于是每个Service方法都在做三件不同性质的事:一是判断“这个业务能不能做”(规则校验),二是执行“这个业务要改哪些数据”(数据变更),三是处理“做完之后要通知谁”(副作用)。这三件事纠缠在同一个方法里,你拆不出、也测不动。
真正的健康结构,第一要务是让“决策”与“执行”分离。业务决策是规则,比如“订单金额满100才能优惠”“库存不足不能下单”;数据执行是CRUD,比如“插入订单记录”“扣减库存数字”;副作用是协调,比如“发送短信”“调用支付网关”。它们不该混在同一行代码里。
要想做到这一点,你得重新审视一个老掉牙的词:事务脚本。很多人对事务脚本嗤之以鼻,说它过时了。但SpringBoot项目里90%的写法就是事务脚本——方法A开事务,做一堆操作,提交。问题不在事务脚本本身,而在脚本里的每个步骤是否具备清晰的语义和边界。你可以用事务脚本,但你不能让脚本里出现“判断用户是否VIP”和“用String.format拼消息文案”这种八竿子打不着的代码挨在一起。
业务逻辑到底应该放在哪里
这是一个永恒的问题:放在Entity里?放在Service里?还是另开一个什么DomainService?我的建议是,先别急着选边站。业务逻辑应该放在“离数据最近,且最不会变”的地方。如果一条规则只跟某一个实体本身的状态有关,比如Order判断自己是否可取消,那它就该是Order实体的方法。如果一条规则牵扯到多个实体,比如下单要同时校验用户、商品、优惠券,那它才应该被提升为协调性质的“领域服务”。
在SpringBoot里,我们往往把Entity当成一个纯数据容器,加一堆getter/setter,业务规则全部写在上层Service里。这就导致实体退化成了数据库表的投影,而Service退化成了一堆if-else组成的“决策树”。你问这些规则是否被复用?没有。你问这些规则是否可测试?得先Mock三个Repository。贫血模型助长了贫血Service,而贫血Service又反过来让Entity更加无脑,这是一个恶性循环。
别误解,我并不是让你把整个项目改成DDD。你可以在一个方法里完成80%的操作,但你可以让那20%真正体现业务含义的代码住在它该住的地方。比如说,订单取消逻辑里有一段“如果订单已支付且发货前,退款原路返还”,这段规则完全可以抽到Order实体里的isRefundable()方法中。Service只需要调用order.isRefundable(),而不是自己写if (order.getStatus() == 2 && order.getShipTime() == null)。这两种写法,前者是问业务,后者是翻数据库。你写的每一行代码,要么在表达业务,要么在操作数据——混在一起,就是腐败的开始。
用“用例”而不是“层”来组织包结构
想象一下,你刚接到一个需求:“新增一个优惠券领取接口”。你打开项目源码,发现包结构是controller,service,mapper,model,config。你该怎么办?你进入controller,新建CouponController;进入service,新建CouponService;进入mapper,新建CouponMapper;最后在model里放一个Coupon实体。这个流程很顺畅,但当你需要理解“领取优惠券”这个完整业务时,你必须在五个包之间来回跳,而且每个包里都有几十个同级别的类,你不知道哪个类跟哪个类有关联。
更好的组织方式,是用业务用例为顶层维度来分包。比如coupon/receive/下面,专门放接收优惠券这个用例的Controller、Request、Service、Converter。而coupon/下面再放这个聚合根共有的Coupon实体、CouponRepository接口。这样,当你看到coupon/receive时,你就知道这里面的东西都是为“领取”这个动作服务的。高内聚不再是一句口号,而是包结构所强制约束的事实。
这在SpringBoot里完全可行,而且非常简单。你可以把@RestController、@Service、@Repository这些注解照常使用,只是不再按照“技术类型”分文件夹,而是按照“业务能力”分模块。注意,这不是“微服务拆分”,而是“代码组织层面的聚合”。一个receive包内,Controller只管HTTP协议转换,Service只管应用逻辑协调,而领域规则放在Coupon或CouponDomainService里。调用关系从controller -> service -> repository变成了“包内自治”。依赖方向永远从外层向内层,内层不知道外层是谁——这就是清淅的骨架。
控制器的厚度:只做协议翻译
Controller经常被骂“胖”或者被夸“薄”,但很多人对厚薄的定义很模糊。你的Controller里如果出现了Map<String, Object>当参数,然后手动get("name")再强转,那它已经胖得离谱了。一个健康的Controller,应该只做三件事:解析HTTP参数、调用一个应用服务方法、把结果转换并封装成HTTP响应。其他事,比如参数校验、业务异常捕获、脱敏、幂等控制,都不应该放在Controller里。
有朋友会说,不是有@Valid注解吗?参数校验放在DTO上不就行了吗?对,但我想提醒你区分“输入格式校验”和“业务规则校验”。@NotBlank(message="用户名不能为空")是格式校验,写在DTO里没问题。而“用户名长度必须在6到20之间”其实也是格式校验,也可以。但“该用户名已被注册”或者“用户等级不够不能领券”就是业务规则了,这类校验必须委托给Service或领域方法。把业务规则混进DTO的注解里,是另一种隐藏的行贿行为,因为你会以为校验已经做了,实际上它只覆盖了表面。
还有一个被忽略的厚度问题:响应装配。很多项目里Controller直接返回Entity,让Jackson去序列化懒加载属性,结果N+1问题频出。又有项目用了一个叫BeanUtils.copyProperties的万能工具,把Entity和VO互相拷贝,但字段名一对不齐就静默出错。我建议你给每个用例定义清晰的“请求对象”和“响应对象”,并且在Controller里用一个专门的Assembler类来装配。装配逻辑也是一种业务逻辑,它决定了什么能露出去、什么不能。
让“变化点”显性化:用策略替代万级if
业务代码里最脏的不是循环和判断,而是暗含在if-else里的隐含策略。比如运费计算,“普通用户10元,VIP用户5元,全场满99包邮”,这三个规则如果写成三个if嵌套在Service里,那么后续增加“保税仓商品额外加关税”时,你只能继续往这个if堆里加条件。比较先进的做法是定义一组ShippingFeeCalculator策略接口,然后每个策略一个Spring Bean,通过Map<String, ShippingFeeCalculator>注入。这样新来的规则就是一个新文件,不需要改动原有代码。
这时候,SpringBoot的依赖注入成了你的得力助手。你可以在一个CalculatorRegistry里根据上下文的特征选择策略,也可以用@Order注解控制优先级。结构清晰的项目,往往能看到“多态”而不是“分支”。分支是线性思维的结果,多态是面向对象的初心。如果你发现一个Service方法里超过十个if-else,而且每个if都是关于同一维度(比如状态机、会员等级、渠道来源),那就果断考虑拆策略。
但请务必注意,策略模式不是为了炫耀而用。如果你的分支只有两个,且将来大概率不会增加,那一个if就够了。过度抽象是另一种混乱。关键是要识别“变化点”——那些业务上预期会频繁追加条件的地方。识别的方法是看你的equals和containsKey出现了多少次。十个状态判断?那不如把状态流转画成一张表,用StateMachine或者Map来驱动。规则越多,越需要一个显式的“规则集合”结构,而不是散落的if。
事务的艺术:别把长流程炖成一锅粥
很多Service方法上顶着一个大大的@Transactional,仿佛要保护一切。但你有没有想过,事务的隔离性和原子性是有代价的?如果一个方法里既操作了数据库,又调用了外部HTTP接口,那么整个事务会长时间持有数据库连接,而那一次外部调用往往要两秒钟。这期间你的数据库连接池在哭泣。事务应该只包裹需要原子性的数据变更,而不是包裹整个业务流程。
拆分事务边界,是对结构清晰的一大贡献。比如下单用例:“校验优惠券”(无状态)、“扣库存”(需事务)、“创建订单”(需事务)、“发送消息通知”(非事务)。你可以把扣库存和创建订单放在两个独立的事务方法中,用TransactionTemplate控制,或者在Service内部通过自调用绕过代理时小心使用。很多老码农会告诉你,Spring事务代理在同类内部方法上不生效,于是他们干脆把整个方法加@Transactional图省事。这恰恰是结构不清晰的前兆。事务边界应该和业务用例的原子性边界重合,而不是和方法的物理长度重合。
更具体地说,当一个@Transactional方法内出现以下任何一项时,你就该醒了:调用远程HTTP、发送MQ消息、循环里逐条单查数据库、对集合进行复杂的内存计算。这些都意味着要么事务过长,要么你执行了本不属于这一“变更单元”的操作。一旦你意识到事务是限界,你自然就会把副作用(通知、审计、发布事件)挪到事务提交之后去执行——这需要事件机制,也就是Spring的@TransactionalEventListener。这个监听器能让你的代码从“时序混乱”走向“因果清晰”。事务提交后发事件,事件处理器再干脏活,主流程就干净了。
警惕工具类与“万能Service”的合谋
几乎每个项目都会有一个CommonUtils、BizUtils或者OrderUtil,里面躺着各种“不想归类”的方法。这些方法往往形迹可疑:有的从线程上下文里取当前用户,有的做日期格式转换,有的将订单号加密,有的把List转成树。当这些方法被超过三个地方使用时,它们就变成了隐式的共享依赖。你每多一个工具类,就多一个公共厕所,谁都能进,谁也不负责打扫。
我并不是说所有工具方法都必须有归宿。像StringUtils,ObjectUtils这种纯静态无状态的方法,放工具类无伤大雅。但一旦某个“工具方法”引用了ApplicationContext、或者依赖了某个Mapper、或者需要从Redis里读取配置,那它就不是工具,而是被垃圾包装的Bean。这时候你要做的是给它一个明确身份:它是UserContext还是OrderNoGenerator?给它起个正经类名,放入对应的业务包中,并注入它,而不是调用静态方法。一个类的名字就是它的契约,叫“Util”意味着它没有契约。
万能Service更是如此。OrderService是一个业务入口,但如果你发现它还承担了InventoryService的职责、UserService的职责,那说明你的服务粒度错了。记住,不要用“Service”掩盖集合体的性质。如果一类业务有自己的数据和规则,就单独建一个服务甚至一个包。不要怕类多,怕的是类里的职责多。类多但职责单一,代码就像书架上一排排整齐的书;类少但每本都是论文集,你每次找内容都得翻目录。
结构清晰是写给未来同事的情书
你可能会说,道理我都懂,但项目里别人都那么写,我也只好随大流。这大概就是“破窗效应”在代码库里的写照:一旦有人开始在一个Service里堆2000行,下一个人的心理门槛就会降低到3000行。如果所有人都觉得“反正会乱,我也乱写一下”,那这个项目就再也无法拥有清晰的状态了。反过来,如果你在每次提交代码时都坚持让新增的类只服务于一个变更原因,拒绝往已经烂掉的Service里再塞一段逻辑,那么即使历史包袱重,你至少没有继续加剧它。
保持结构清晰没有银弹。SpringBoot给了你快速启动的能力,但没给你免于思考的豁免权。你需要做的事情说到底很朴素:把业务规则从数据操作里捞出来,把编排逻辑从业务规则里剥下来,把协议转换从核心逻辑里摘出去。你可以通过包结构强制依赖方向,通过策略模式管理变化点,通过事件机制隔离副作用,通过DTO限定可见数据。你会为此多写几个类,多拆几个方法,多花半小时思考这个if该放在哪个层——但这半小时的思考,能为你和你的同事省下未来无数个凌晨三点的排查时光。
代码终究是写给人类看的,只是机器碰巧能运行而已。当你下一次打开一个Service准备新加一个方法时,先问自己:这个功能属于这个类吗?这个方法里的每一步都在同一抽象层级吗?如果答案是否定的,就别急着写,先拆。每一次“凑合一下”,都是在为下一片混沌投下重磅炸弹。结构清晰从来不是某一次重构的成果,而是无数次小决策的组合。今天你少写一个Utils方法,明天你少在一个方法里加一个分支,后天你为一个计算规则新建了一个策略类——这些微小的坚持,才真正定义了代码的最终形态。
你不需要发明什么架构,也不需要引入什么牛逼的框架。你只需要在每次提交之前,用指尖划过那些新增的代码,想象一下一个刚入职的同事顺着调用链一路看到这里,他会不会觉得这条路是通的、亮的、干净的。如果是,那你的SpringBoot项目,就已经具备了最昂贵的东西——清晰的结构,它是不依赖任何人天赋的,可以持续积累的资产。