首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
从CRUD到模块化:后端领域拆分的演进路径与实战方法
📅 2026/10/13 21:36:41
✍️ 爱科研究院
👁 阅读 3,247
做了好几年后端我见过太多项目从“清爽好用”慢慢变成“改不动”。举个例子一个刚上线的业务系统用户表、订单表、商品表Controller、Service、Mapper 三层结构增删改查十几个接口当时写起来是真的快。但当业务跑到第二年功能开始交叉、权限开始纠缠、数据字段开始互相依赖的时候那种“写 CRUD”的快乐就完全消失了。这篇文章想聊的就是一条从 CRUD 到模块化的演进路径以及后端的领域拆分到底拆的是什么东西——用一句话说领域拆分就是服务端的组件化。我为什么说这个事值得聊因为很多团队并不是不知道要“拆分”而是不知道拆分到底该怎么做。有人一上来就拆微服务拆完变成“微服务灾难”有人把包结构整理了三天结果只是把类挪了个位置业务复杂度一点没降。这里面的核心问题在于没有理解拆分的本质拆分服务端项目不是在拆代码而是在拆业务的责任边界。适合谁来读这篇文章工作一两年、正在维护中等规模业务系统的后端开发以及那些想给项目动刀但没有头绪的架构师或团队负责人。文章不会讲特别玄的理论主要是我在实际项目里走了一遍之后的思路和做法。1. 先认清问题CRUD 项目在什么阶段开始失控1.1 典型 CRUD 项目的结构画像先看一个典型的 CRUD 项目长什么样。我接触过的不少老系统包结构都是这样com.company.demo ├── common │ ├── Result.java │ ├── PageQuery.java │ └── BizException.java ├── controller │ ├── UserController.java │ ├── OrderController.java │ └── ProductController.java ├── service │ ├── UserService.java │ ├── OrderService.java │ └── ProductService.java ├── mapper │ ├── UserMapper.java │ └── OrderMapper.java └── entity ├── User.java ├── Order.java └── Product.java这种结构的最大特点是按“技术层”组织代码而不是按“业务领域”组织代码。所有 Controller 堆在一个包里所有 Service 堆在另一个包里所有表的实体类放进一个 entity 包。这种项目在最初几十个接口的时候问题不大反正一眼看得到头。真正的变化发生在业务增长之后。比如用户这块开始有了注册、登录、实名认证、邀请关系、积分体系订单这块开始有了下单、拆单、售后、退款、走私单流程。此时如果你还维持这种技术层分包方式就会出现一种很常见的现象UserService 里同时处理了用户信息校验、订单订阅关系、积分扣减逻辑。你会发现这个类越来越长最后长到一千多行没人敢动它。我见过最典型的例子是某个营销活动功能为了给用户发一张券在 UserController 里加了接口在 UserService 里写了发券逻辑直接查了优惠券表又改了订单表的一个状态字段。这三个动作横跨了三个业务域但因为都在“同一个 service 层面”做的人不觉得有问题代码分层也都“符合规范”——Controller 调 ServiceService 调 Mapper。可是从业务角度讲用户、优惠券、订单这三个模块已经被强行耦合在一起了。1.2 失控的三个信号如果你不确定自己的项目还有没有救可以对照下面三个信号。好消息是出现这些信号不意味着要重构但它说明你已经站在“要不要拆分”的十字路口了。第一个信号是改需求要动多个模块。以前加一个字段改一个文件就行。现在加一个“会员等级”字段可能要动用户表、订单表、商品价格计算、优惠券发放规则、甚至客服查询列表。每次排期都要说“这个需求涉及五个地方”团队负责人心里开始打鼓。第二个信号是循环依赖开始出现。在 CRUD 项目里跨领域调用是直接用 Service 互相调用户模块需要订单数据订单模块又需要用户数据于是 UserService 和 OrderService 你引用我、我引用你。编译没问题因为是同一个工程运行时也没问题因为不是微服务。但代码的可读性和可测试性已经明显恶化了测 UserService 时你不得不 mock 一堆 OrderService 的行为而这些行为又依赖 UserService 自己。第三个信号是测试和部署变得越来越重。改动一个订单状态枚举想跑一遍单元测试发现需要构建整个上下文加载几十个 Bean时间长得让人想放弃写测试。发一个上线包需要把整个应用停掉重新编译、打包、部署一个两三秒的小功能也要走完整流程。这三个信号背后的本质是“认知负载过高”——你已经无法在脑子里装下这个项目的全部结构了。此时最糟糕的应对方式是“继续往里码代码”最好的应对方式是停下来做结构演进。1.3 “能跑”和“能维护”是两码事很多团队对代码的要求就是“能跑”。这个标准本身没什么问题但“能跑”是一个静态概念它没有把“未来六个月继续在这个系统上加功能”的成本算进去。我常用一个类比代码项目像一间不断往里放家具的房间。CRUD 阶段房间里只有一张桌子和一把椅子怎么摆都行业务多了之后房间里塞满了柜子、沙发、杂物你再要放一张新书桌就得先挪开一堆东西有时候还可能碰倒旁边的花瓶。模块化做的事情就是提前给房间隔好区域这片是客厅、那片是餐厅、角落是书房。东西还是那些东西但每件物品有了自己的归属你要动其中一件时影响范围是可预测的。从“能跑”到“能维护”的转变是每个后端团队都要经历的坎。区别只在于你是主动规划的还是等代码爆炸之后被动重构的。我强烈建议前者因为被动重构的时候业务通常正卡在一个必须快速交付的节骨眼上你根本没有时间做结构上的思考。2. 领域拆分的内核它为什么等于服务端组件化2.1 拆的是业务边界不是表结构很多后端听到“领域拆分”第一反应是拆库拆表把用户表放一个库、订单表放另一个库、商品表再放一个库。这是最常见的误解。领域拆分拆的是“逻辑边界”也就是业务责任边界而不是物理的数据存储边界。我比较认同一种说法领域Domain是一组高内聚的业务能力和业务规则。它有自己的业务语言和数据模型自己的变化节奏以及自己需要保障的不变量。比如“订单”这个领域它关心的不是用户表里有多少字段而是“订单创建流程要保证哪些状态合法”“什么情况下允许取消”“取消后库存怎么释放”这些业务规则。当你用这个视角去看现有项目时你会发现原来很多代码放错了位置。比如“下单时校验用户是否有足够积分抵扣”这段逻辑它到底是用户域的还是订单域的按职责来说它应该是订单域的只不过它引用了用户积分数据。如果你把这个逻辑写在 UserService 里那就把订单域的规则泄露到了用户域。换个角度理解为什么说领域拆分等于服务端的组件化因为前端做组件化的核心思路是“按功能边界封装对外只暴露接口内部实现可以独立变化”。后端领域拆分做的事一模一样。它把一个大的业务流程拆成若干个高内聚、低耦合的组件每个组件负责一块清晰的业务能力对外用接口表达能力内部怎么实现、怎么存储外部不关心。这跟前端拆一个按钮组件、一个表单组件的思路没有本质区别只不过前端的组件边界是视觉效果和交互行为后端的组件边界是业务规则和数据归属。2.2 组件化的三种形态模块、服务、库领域拆分之后组件在代码层面以什么形态存在我梳理下来基本是三类。第一类是模块化单体Modular Monolith也就是在同一个进程里把它切成多个 Maven/Gradle 模块模块之间通过接口依赖单测和启动仍然是一个应用。这是我认为大多数中小团队的第一选择原因后面再说。第二类是微服务Microservice也就是把领域组件独立部署成进程通过网络调用通信。它适合团队人数多、模块独立部署诉求强、单进程实在撑不住的场景。第三类是共享库Shared Library也就是把那些高度稳定、几乎不变的公共能力抽成 Jar/Package比如统一的序列化工具、统一的异常模型、统一配置读取。共享库是很容易被滥用的形态很多团队把“公共业务逻辑”也放进共享库里结果改一个公共方法所有依赖它的模块都要跟着重新发布这反而是另一种耦合。三种形态不是互斥的更多情况下是组合的一个项目先用模块化方式拆清楚内部共享库保留技术底座等某个子域真的需要独立部署时再把它抽成服务。这个演进路径比“一开始就上微服务”稳妥得多。2.3 领域拆分和微服务的关系作为常年看后端项目的人我必须强调一个观点领域拆分不等于微服务它只是微服务的前提但不是充分条件。很多团队把这两件事混在一起导致在还没有理清业务边界的情况下就开始拆服务结果服务边界约等于原来的 Controller 边界拆完还是一团乱麻。把领域拆分理解成“给房子隔房间”微服务理解成“在房间之间加宽带网线”。你完全可以先隔好房间、不拉网线模块化单体也可以隔好房间又拉了网线微服务但你不可能不隔房间就直接拉网线那只会让所有东西继续堆在客厅里还多了一堆网络开销。从企业真实项目的节奏来看最稳妥的路通常是先做代码层面的领域梳理让每个领域在逻辑上独立高内聚再通过构建工具把领域拆成模块让依赖关系显式化等团队规模、业务复杂度都上来了再考虑把模块独立部署成服务。走完前三步你其实已经完成 80% 的工作了剩下的 20% 只是把接口调用换成远程调用而已。这也是为什么标题里要强调“从 CRUD 到模块化”——对企业真实项目来说模块化本身就是一种高价值的架构形态不需要用微服务来证明自己“先进”。模块化做得好后续演进是顺水推舟模块化做得不好拆出来的微服务只是把锅分成了几个小锅里面的饭菜还是一锅浆糊。3. 演进蓝图从 CRUD 到模块化的一条现实路径3.1 阶段一包结构层面的领域梳理第一步不写新代码不改数据库不调整部署方式只做一件事把现有代码按领域重新组织。很多人觉得这个步骤不够“爽”但它恰恰是整个演进里最重要的一步因为它决定了你对业务边界的判断是否准确。具体操作是这样的。把你系统里的核心业务功能一个个列出来比如“用户注册”“用户登录”“用户积分”“下单”“订单查询”“订单取消”“库存扣减”“库存盘点”“支付回调”“退款”。然后把这些功能按“业务能力”进行聚类。聚类标准不是它们是否访问了同一张表而是它们是否服务于同一个稳定的业务目标。比如“用户积分”和“用户注册”虽然都在用户表上操作但积分更接近营销体系如果团队里有人专门负责营销那积分类功能就可以单独是一个候选领域。聚类之后你会得到几个候选领域。此时先不要急着建 module先用包结构调整代码位置。把你现在的包结构改成按领域分包com.company.demo ├── user │ ├── controller │ ├── service │ ├── repository │ └── model ├── order │ ├── controller │ ├── service │ ├── repository │ └── model └── product ├── controller ├── service ├── repository └── model这个步骤虽然只是挪位置但价值非常大。它会倒逼你回答一个问题某个类到底属于哪个领域当你发现一个类里的方法分别属于不同领域时你就找到了第一个需要拆分的“上帝类”。拆解上帝类是阶段一的重头戏我通常的做法是先把方法按业务逻辑归属归类再抽接口最后把实现类分别移动到各自的领域包里。这个过程不需要一次完成可以按一个领域一个领域地迁移每迁完一个领域就跑一遍全量测试。3.2 阶段二模块化拆分与依赖约束包结构调整稳定之后第二步才是真正的模块化用构建工具把每个领域变成独立的 Maven/Gradle module。一个典型的多模块结构大概是这样pom.xml // 父工程 ├── user-module // 用户领域 │ ├── pom.xml │ └── src/main/java/... ├── order-module // 订单领域 │ ├── pom.xml │ └── src/main/java/... ├── product-module // 商品领域 │ ├── pom.xml │ └── src/main/java/... ├── common-module // 公共底座放异常、工具、基础模型 │ ├── pom.xml │ └── src/main/java/... └── application-module // 装配层负责加载所有模块、启动应用 ├── pom.xml └── src/main/java/...这里有一个很容易踩的坑模块之间不能直接互相依赖 Service 实现。正确的做法是每个领域模块对外暴露一个“接口包”或者 API 包其他模块只依赖接口不依赖实现。在代码层面推荐用依赖倒置把领域对外能力定义为接口实现类放在模块内部。启动时通过依赖注入或服务定位把实现组装起来。依赖方向可以用 ArchUnit 之类的测试来守护。我实际用过 ArchUnit 在项目里做依赖分层检查效果非常明显。写一个简单的规则测试保证 order 模块不能依赖 user 模块的内部实现一旦有人违反规则单测直接变红review 时就不用靠嘴去约束了。AnalyzeClasses(packages com.company) class ArchitectureTest { Test void orderModuleShouldNotDependOnUserModuleImpl() { noClasses() .that() .resideInAPackage(..order..) .should() .dependOnClassesThat() .resideInAPackage(..user..) .check(new ClassFileImporter().importPackages(com.company)); } }阶段二最重要的产出不是“工程结构变了”而是“依赖关系被显式定义出来了”。以前你依赖谁靠 import 语句看现在你依赖谁写在模块的 pom.xml 里每个依赖都有理由。3.3 阶段三独立进程与接口化当模块化单体运行了一段时间团队人数增多、某个模块的发布频率显著高于其他模块、单进程的内存或数据库连接已经跟不上时才考虑进入阶段三把某些模块抽成独立服务。阶段三的第一个注意点是不要把所有模块一起抽。先找一个业务边界最清晰、变化最独立的领域做试点。把它的调用方式从进程内方法调用改为 RPC 或 HTTP 调用。为了平滑过渡你可以在接口层做文章先保留模块的本地实现再新增一个远程实现通过配置决定走本地还是走远程。这样即便远程服务出问题还能回退到本地实现不至于一刀切上线。数据存储的拆分要排在服务化之后并且要逐个领域处理。先别急着为每个服务建独立数据库。在模块化阶段多个模块可以共享同一个数据库实例但建议通过 Schema 或表前缀做物理隔离防止出现“一个模块偷偷改了另一个模块的表”这种破窗效应。到了服务化阶段再按领域把 Schema 拆成独立库同时把跨领域数据访问改成接口调用。我见过很多团队在这个顺序上搞反先拆了库再去拆服务结果一个事务跨了三张表被迫引入了分布式事务中间件性能没上去一致性更难保证。这就是我之前说的“被动重构”的典型结局。4. 实操拆解以交易系统为例的领域划分全过程4.1 从需求到子域的映射理论的框架说完了下面用一个我可以反复讲的例子交易系统的领域划分。假设我们需要构建一个电商交易核心系统核心业务包括“用户下单”“库存占用”“支付回调”“订单查询”。先做职责梳理。把核心业务事件和它要完成的业务能力列出来业务事件关键业务能力归属候选领域用户提交订单订单创建、订单状态校验、关联用户信息订单域扣减库存库存预占、库存超卖校验库存域支付成功回调校验支付金额、更新订单支付状态订单域或支付域订单取消释放库存根据取消原因释放库存数量订单域 库存域商品信息查询商品详情、上下架状态商品域这里最关键的一个决策是把“支付”单独划成一个领域还是并入订单域。答案取决于团队对支付的定位。如果支付逻辑很重包含对账、退款、渠道路由、交易流水我倾向于单独拆一个支付域如果支付只做回调更新状态那并入订单域也说得过去。领域边界没有标准答案它的本质是“谁的规则更内聚谁的变化更独立”。对于这个模拟项目我最后划分成四个子域订单、库存、支付、商品。订单是核心域库存和支付是支撑域商品是通用域。核心域意味着业务规则最复杂、最值得投入代码质量支撑域更多是配合核心域完成流程通用域尽量复用现成能力不要写太重。4.2 代码层面的落地细节确定了领域边界之后代码要落实到位。我建议每个领域内部的包结构保持统一方便理解和维护。可以简化为四层com.company.order ├── api // 对外暴露的接口定义和 DTO │ ├── OrderQueryService.java │ └── OrderWriteService.java ├── application // 应用服务负责用例编排、事务边界 │ └── OrderAppService.java ├── domain // 领域层承载核心业务规则 │ ├── model │ ├── repository │ └── service └── infra // 基础设施实现数据库访问、外部接口调用 └── repository └── OrderRepositoryImpl.java依赖方向从外向里api 不依赖 domaindomain 不依赖 infra。这样外部接口的稳定性就不会被内部实现变化影响。举一个实际的接口定义例子。订单域对外提供创建订单能力public interface OrderWriteService { OrderCreateResult createOrder(OrderCreateCommand command); }实现类在 domain 里public class OrderWriteServiceImpl implements OrderWriteService { private final OrderRepository orderRepository; private final InventoryIntegration inventoryIntegration; private final UserIntegration userIntegration; Override public OrderCreateResult createOrder(OrderCreateCommand command) { // 1. 构建订单聚合 // 2. 校验业务规则金额、数量、状态等 // 3. 预占库存通过防腐层调用库存域 // 4. 保存订单 // 5. 返回创建结果 } }注意其中的 InventoryIntegration 是一个防腐层接口它隔离了订单域和库存域。订单域只知道自己需要“预占库存”这个能力不关心库存域是本地模块还是远程服务。将来如果库存模块升级为独立服务只需要更换 InventoryIntegration 的远程实现即可。4.3 数据与事务的取舍业务拆分中最棘手的是数据归属和事务边界。在模块化阶段数据可以共享一个库但要约定“每个领域只允许访问自己的表”。这个约定要通过工具或人去盯盯不住的话跨表访问迟早会侵蚀边界。事务方面我的建议是“能用本地事务解决的绝不引入分布式事务”。在订单和库存还是同库时创建订单和扣减库存可以直接在一个事务里完成这是最简单的方案。但如果你已经决定拆成独立服务就要转变思路不要试图用分布式事务去保证强一致而是接受一个短暂的“中间状态”用最终一致性去恢复。举一个常见做法订单服务创建订单后调用库存服务预占库存。如果库存预占失败订单服务把订单标记为“创建失败”或“已取消”并记录一条失败原因。如果订单创建成功但库存预占迟迟没回结果那就通过定时任务去对账查订单状态、查库存占用记录两边对上就继续对不上就补偿。这个过程用生活类比就是你下单后库存那边说“正在确认”这时整个流程并没有完成但业务上可以展示“处理中”等到对账确认完才真正变成“成功”。这就是用状态机的显示转换替代“一刀切”的事务回滚简单、可控、也好排查。5. 常见问题与排查技巧实录5.1 典型问题速查表我在帮某个模拟项目做结构演进时积累了一些很典型的问题整理成一张速查表方便大家自查问题典型表现根因处理方式拆得太细模块数量比开发人数还多每个模块只有三五个类按“感觉”拆没有按业务能力聚合合并小模块按“业务变化频率”重新分组边界划错新需求一上来就要跨三个模块改接口子域划分时没梳理业务事件流重新梳理“谁产生事件、谁消费事件”调整边界共享库膨胀common 模块里放满了业务类改一次拖累全部把公共业务逻辑硬塞进共享库共享库只留稳定技术底座业务代码让渡给领域模块依赖方向被破坏一个模块的工厂方法间接 new 了另一个模块的实现模块间边界没有用接口约束用 ArchUnit 守护改用接口注入分布式事务泛滥跨服务调用后数据不一致要靠人工修强行用分布式框架做强一致改最终一致性用本地消息表定时补偿5.2 跨域事务和补偿的实际案例有一个我踩过的坑值得单独说。某模拟项目在拆分之后把“订单创建”和“库存扣减”拆到了不同的服务但最初代码仍然在一个本地事务里先调用远程库存接口再提交本地数据库事务。问题在于远程接口已经扣了库但本地事务提交失败结果就是订单没建成功库存却没了。解法是把“调用远程库存”这个动作放在本地事务提交之后执行并且把库存服务的调用记录成一条事件消息放到一张本地消息表里。如果调用库存成功就更新消息状态如果失败定时任务会重新发送消息或者触发人工处理。步骤如下订单落库状态为“创建中”同时写一条“库存预占消息”到本地消息表同事务。事务提交后后台异步把“库存预占消息”发给库存服务。库存服务处理成功回调订单服务订单状态改为“已创建”。如果回调超时或失败定时任务重发消息或触发对账流程。这个方案的代价是需要维护一张消息表和一套对账逻辑但好处是避免了分布式事务的复杂度和性能损失。在实际业务里大多数跨域场景都能用这种最终一致性思路覆盖。这不算新鲜但确实比“遇到跨服务就上分布式事务”要可靠得多。5.3 过度设计的预警线领域拆分过程中另一个极端是过度设计。我看到过有人为了“未来可能需要多租户”给所有表加租户字段为了“未来可能需要国际化”给所有 DTO 加语言字段结果未来没来复杂度先来了。我个人的判断标准有三条第一拆分的最小单元必须对应一个真实业务团队的责任边界。如果团队里没有人对这个领域负责拆出来无人维护那它就是多余的。第二接口数量不能大于实际调用场景。如果你给订单查询设计了五个不同维度的查询接口但实际只有一个页面在用那应该收敛成一个通用查询接口。第三每一次新增业务功能不应该要求同时修改多个模块的接口定义。如果频繁出现“为了加一个字段三个模块都要发版”说明有一个抽象抽错了层或者边界划偏了。做架构演进最怕的不是慢而是朝着错误的方向加速。模块化的出发点永远是“降低维护复杂度和变更成本”一旦发现某个拆分动作没有让这两点变好甚至变得更糟就要停下来诚实复盘。最后再分享一点个人体会拆分这件事做过一次和没做过对代码结构的理解完全不一样。我第一次做模块化拆分时最大的错误是把“领域”理解成了“表的分组”——按数据库表之间的关系去划模块结果拆完之后原先写在同一个 Service 里的业务规则散落到了好几个模块事务逻辑全乱了越改越痛苦。后来退回去从业务事件的角度重新梳理了一遍才真正明白“领域”是业务责任的边界不是数据表的边界。我最后留下的一个实用习惯是在动代码之前先画一张“领域地图”。把业务事件从入口到落库的完整链路走一遍标出每一步属于哪个候选领域、产生了什么事件、消费了什么数据。这张图比任何架构图都管用它能让你在动手之前看清所有隐藏的耦合。如果你正准备给项目做演进不妨先试试这个动作很多时候答案会在画图的过程中自己浮出来。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/13 21:36:41
开源工作流助手深度实测:从部署到任务编排核心功能解析
2026/10/13 21:36:41
从CRUD到领域拆分:后端模块化架构演进之路
2026/10/13 21:36:41
通达信缠论K线包含处理插件源码:从合并算法到分型笔的完整实现
2026/10/13 22:46:50
数据库课程设计报告:实验室设备管理系统ER图与SQL建表全链路
2026/10/13 22:46:50
748GB统一内存桌面工作站:万亿参数模型本地部署与电力改造实战
2026/10/13 22:46:50
OpenVR、OpenXR、SteamVR与厂商SDK:概念梳理与迁移指南
2026/10/13 22:46:50
AI搜索时代,官网内容优化与企业级RAG谁先做?
2026/10/13 22:46:50
Java实现SECS/GEM报文解析:格式码、长度编码与嵌套List详解
2026/10/13 22:41:47
基于C51单片机的电子密码锁:矩阵键盘、电磁锁驱动与掉电保护实战
2026/10/13 0:00:40
物联网系统集成避坑指南:从选型到验收的关键要点
2026/10/13 0:00:40
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查
2026/10/13 0:00:40
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析
2026/10/13 12:25:09
流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南
2026/10/13 18:15:04
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别
2026/10/13 8:14:22
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容
2026/10/12 19:53:23
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/11 21:41:11
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/11 23:43:10
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)