# 三层架构不是分文件夹:从一次积分扣减重复执行说起
很多项目把 controller、service、model 拆成三个文件夹,就觉得架构已经干净了。但真正出问题的时候,往往不是文件放错了位置,而是职责边界没划清。我最近排查了一个典型故障:用户兑换积分后,积分被扣了两次,日志里两条扣减记录间隔不到 50 毫秒。
查下来的原因不复杂:controller 里先调了一次 service 的扣减方法,又在事务提交后补了一次“兜底扣减”。写代码的人当时的理由是“怕第一次没成功”。这个理由本身就暴露了分层没拆干净——如果 service 层的方法不能让人信任它会把事做完,那说明边界从一开始就是模糊的。
下面按我自己的实践,说清楚三层各自该管什么、不该管什么。
Controller:只做翻译,不做决策
Controller 的职责是把 HTTP 输入翻译成 service 能理解的参数,再把 service 的返回翻译成 HTTP 输出。它不应该判断“这个用户有没有权限扣积分”,也不应该决定“扣失败要不要重试”。一旦 controller 里出现 if 判断业务规则,或者出现第二次调用同一个 service 方法,基本就是越界了。
一个可操作的检验标准:把 controller 的方法体读一遍,如果里面除了参数校验、调用 service、组装响应之外还有别的逻辑,就值得警惕。参数校验也仅限于格式层面——比如积分必须是正整数,这属于输入格式;但“用户余额够不够扣”属于业务规则,归 service。
Service:业务规则的唯一入口
Service 层要回答的问题是“这件事在业务上怎么算做完了”。扣积分这个动作,业务上的完整含义包括:检查余额、写入扣减记录、更新余额、处理并发。这四件事必须在同一个事务边界内完成,而且只能有一个入口方法。
那个重复扣减的故障,根子就在于 service 没有把“完整做完”这件事封装好。正确的做法是:service 的 deduct 方法内部用事务包住全部步骤,调用方只调一次。如果担心失败,应该在 service 内部做事务回滚,而不是在 controller 里补第二次调用。
另一个常见误区是把缓存刷新放在 controller。缓存属于数据可见性的一部分,应该由 service 在事务提交后统一处理。放到 controller 会导致:任何绕过这个 controller 的调用路径都不会刷新缓存。
Model:只描述数据,不写业务
Model 层最容易被写胖。很多项目把“扣积分”写成 model 的一个方法,里面塞进余额检查、记录写入、甚至发通知。这样做的后果是:业务规则散落在 model 里,service 层被架空,换一个存储方式就要重写业务逻辑。
我的建议是 model 只保留三类东西:字段定义、基础查询构造、以及纯粹的数据格式转换。凡是涉及“多个步骤配合才能完成”的逻辑,一律上移到 service。判断标准很简单:如果这个方法需要开启事务,它就不该待在 model 里。
一个反直觉的结论
三层拆得干不干净,不看文件夹结构,看的是“调用次数”。如果 controller 里对同一个 service 方法的调用超过一次,或者 service 里对同一个 model 方法的调用出现在事务外,基本可以判定边界有问题。干净的拆法有一个朴素的特征:每个业务动作,从 controller 到 model,是一条单向的、只走一遍的调用链。
你现在的项目里,有没有哪个 controller 方法调了同一个 service 两次?如果有,那可能就是下一个重复执行的入口。