做后端开发这几年我最大的感触是大部分项目并不是被业务复杂本身压垮的而是被没有跟着业务一起演进的结构拖垮的。CRUD 写起来很爽一个控制器、一个服务、一个数据访问接口数据库表一映射接口一铺开看起来整整齐齐。可当业务从几千行膨胀到几万、几十万行团队从两三个人变成五六个小组并行迭代时那套“表驱动”的线性结构就开始处处掣肘。本篇文章要聊的就是从这个阶段到模块化架构之间的一条真实演进路径——也就是后端的“领域拆分”。它本质上解决的不是技术问题而是组织协作和变更控制的问题。把这套思路想明白你就会发现后端模块化和前端组件化其实是同一件事把大系统拆成高内聚、低耦合、可独立演进的组件只是后端的组件叫“领域模块”而已。1. 从 CRUD 到模块化项目结构演进的三个阶段1.1 第一个阶段线性式 CRUD很多项目起步时都是同一个模子数据库里建几张表后端对应建三层——Controller、Service、Mapper或者 Repository。三层结构本身没有错但业务逻辑的落点往往非常随意。最初做某跨平台系统的订单模块时我就是这么写的订单查询直接在 Mapper 里写一个带十几张表 join 的大 SQL再让 Service 直接调用它新增订单时在 Service 里堆了三百多行代码一边校验库存、一边扣减余额、一边生成流水。当时看起来效率挺高一个功能一两天就出来了。这种结构在业务量小的时候确实好用代码少结构简单新来的同事半天就能上手。可问题也会随着代码量同步增长。最明显的是重复代码。同样的库存校验逻辑在订单服务里写一遍在售后服务里又抄一遍等某天规则调整时你得全局搜索手工修改改了这处漏了那处。其次是数据视角代替了业务视角Service 方法按表来组织一个订单相关的操作被拆散在订单表和订单明细表的两个 Service 里真正完整的业务链路是割裂的。到了这个阶段一些团队会进入表面治理把类搬进不同的包比如 controller 包下建 order、stock、payment 三个子包service 包下也建同样的三个子包。包名看起来是模块化了但代码之间的依赖关系并没有改变。业务逻辑照样互相引用一个订单服务里照样直接操作库存的 Mapper。这种“伪模块化”只是把标签贴到文件系统里本质上仍然是线性式 CRUD。1.2 第二个阶段按业务切分包名但没切出边界伪模块化有一个很典型的现象你在 IDE 里按住 Ctrl 点击某个类的引用会发现自己可以随意跳进任意一个业务包中直接调用别人包的内部方法。系统在编译期完全不阻止这种越界访问甚至在代码评审时也很难看出问题——毕竟调用链是通的测试也过了看起来一切正常。为什么说这种状态没有切出边界因为模块化的核心不是看你怎么组织文件而是看依赖方向是不是清晰、可控的。真正的模块必须有边界边界意味着“我能用哪些接口、不能用哪些内部实现”是被结构性强制的。仅仅把 class 文件分门别类放到不同的 package 里只是视觉上的整理依赖图依然是乱麻。我当时在模拟项目X上做过一次自查用工具导出了整个项目的包依赖关系图发现几乎所有业务模块之间都存在双向或者网状引用。最糟糕的是订单模块同时依赖了库存模块的内部 service 和支付模块的内部 mapper而支付模块为了做一些统计查询又反过来直接读取订单表。这种结构下任何一个模块内部改造都会像推倒多米诺骨牌一样传导到其他模块团队并行开发的自由度几乎没有。如果停留在这一阶段常见的应对手段就是“加人”和“开更多会议”。需求一变相关小组得坐在一起对影响面每次发布都是个大发布稍有疏漏就线上出事故。真正跳出这个困局不是继续在现有结构上打补丁而是要从构建层面入手把模块变成编译单元让边界从“约定”升级为“强制”。1.3 第三个阶段构建层面的模块化与领域拆分构建层面的模块化在 Java 生态里最常见的做法是 Maven 多模块或者 Gradle 多工程每一个领域模块对应一个独立的构建单元。这时候模块之间不能再靠“包名友好”来协作而是必须在pom.xml里显式声明依赖。你依赖了别人就必须看得到别人暴露出来的接口你没依赖别人就算包名再像也无法调用别人的代码。这一步看起来只是工程配置上的调整实际影响非常深远。它会逼着你回答一个问题这个类到底应该属于哪个领域谁依赖谁是合理的做几个模块之后你会发现以前那些随意 cross-call 的代码在模块边界面前无处遁形你必须在拆分之前就想清楚它们的归属。这个思考过程就是在做领域拆分。下表整理了三个阶段的核心差异阶段组织方式是边界强制程度依赖方向业务响应能力线性式 CRUD按数据库表层无边界service 互相乱调勉强可做伪模块化按包名分组仅靠约定网状依赖已开始吃力真正模块化按领域构建拆分编译期强制单向依赖可支撑并行迭代第三阶段就是标题里说的“领域拆分”。它把后端的服务端拆成像前端组件一样的东西每个领域模块有自己清晰的职责范围、只暴露必要接口、内部实现可以随时替换而不影响外部。这也是我后来在多个企业项目里反复应用的思路。2. 领域拆分的底层逻辑为什么说它是服务端的组件化2.1 领域是一组高内聚的业务能力很多文章一谈领域拆分就搬出整套领域驱动设计DDD术语把新人吓得够呛。其实把它拉回到最朴素的逻辑上看就一句话领域就是一组高内聚的业务能力集合。举个例子一个订单领域模块里面应该包含创建订单、修改订单、查询订单、订单状态流转这些能力一个库存领域模块包含库存查询、库存冻结、库存扣减、库存回滚这些能力。那么问题来了一个“下单”动作它同时涉及订单、库存、支付这些逻辑到底该放在哪个领域里判断标准就是“谁是这个业务动作的数据属主”。订单的主数据订单号、商品明细、金额快照属于订单领域所以创建订单的流程编排放在订单领域里做主逻辑但要不要扣库存、要不要扣钱这些后续动作要交给对应的领域处理。如果让订单领域自己去直接改库存表的数据那库存领域就名存实亡了——一个组件直接修改另一个组件的内部状态等同于在组件化系统里打破了依赖边界。这种思维和前端组件化是一样的。前端一个支付按钮组件不会直接去修改订单列表组件的内部 state它只能通过事件或回调通知父组件由父组件完成数据流转。后端领域模块也是一个道理订单模块发出一条“订单已创建”事件库存模块监听后执行自己的扣减逻辑彼此都不需要知道对方内部是怎么实现的。2.2 边界、依赖方向与防腐层领域模块一旦建立就要维护两条铁律第一依赖方向必须是单向的上层应用模块可以依赖领域模块领域模块之间只能通过接口依赖绝不允许出现领域模块反向依赖上层应用的情况第二领域模块之间不能直接操作对方的表或内部仓储只能通过对外暴露的接口或事件协作。有些读者会问既然模块之间要协作那直接提供接口让别人调用不就行了吗还缺什么缺的是防腐层。防腐层是个很形象的词它像一个转换适配器放在本领域模块的入口负责把外部传来的数据翻译成领域内部模型同时把内部模型翻译成外部能理解的数据结构。为什么要多这一层因为领域内部模型是服务于本领域业务规则的它和外部接口的传输对象DTO往往长得不一样。比如库存领域内部有一个“可售数量”的概念它由物理库存减去锁定库存再减去预占库存计算得出但下游订单模块在下单时只需要知道“是否可售”那么防腐层就可以在入口处完成这个换算不用把整个内部模型暴露出去。一旦内部库存逻辑变更只要防腐层对外输出结果不变下游就完全感知不到。用生活里的例子类比这就是插头的转换接头。不同国家电压和插头标准不一样但转换器统一输出你家电器的接口标准电器本身不需要改造。领域模块间的防腐层就是业务世界里的转换接头。2.3 领域间协作的三种典型方式领域模块设计好之后协作方式一般有三种。第一种是同步接口调用适合强一致、实时性要求高、且链路短的操作。比如查询订单详情时需要同时展示商品基础信息订单模块调用商品领域的查询接口获取数据这就是典型的同步调用。第二种是事件通知适合链路长、可以异步化、或者多方都需要感知同一事实的场景。订单状态变成“已支付”后触发支付成功事件订单模块推送消息物流模块、积分模块、发票模块各自监听并发起自己的处理逻辑。事件的好处是订单模块不需要知道自己会被谁监听新增一个下游模块时上游代码完全不用改动这天然符合组件化的解耦要求。第三种是共享内核也就是把一些非常通用、不可能归属到任何单一领域的概念抽出来共用比如货币类型、金额对象、业务异常基类。和这三种方式配套的是一套反直觉的原则领域之间宁可事件多、查询少调用也不要为了省事直接互相钻内部。我见过不少团队在拆完模块后仍习惯性地同步调用各个领域的内部服务来做流程编排因为这样看起来简单直接。后果是模块间的耦合度虽然比之前好了点但调用链依然是一条超长的同步链任何一个环节慢一点整个下单接口就跟着变慢。把低频、非关键的环节改成事件驱动链路会缩短很多。这块没有绝对对错取舍的关键在于你判断这条链路上哪些动作需要同步返回结果给用户哪些可以接受最终一致。3. 实操落地从零拆一个真实业务模块3.1 目标结构长什么样理论讲完还是要看具体怎么落地。我用一个虚构的模拟项目X来演示它包含用户、订单、库存、支付几个主要业务最初是单个 Java Web 工程。按领域拆分后Maven 多模块结构大致是这样mock-project-x/ ├── pom.xml ├── application/ │ ├── order-app/ # 订单相关接口聚合层 │ ├── admin-app/ # 后台管理接口聚合层 │ └── open-app/ # 对第三方开放的接口聚合层 ├── domains/ │ ├── user-domain/ │ ├── order-domain/ │ ├── stock-domain/ │ └── payment-domain/ └── support/ ├── common-utils/ # 纯工具不依赖任何领域 ├── messaging/ # 消息封装 └── openapi/ # 对外 API 定义与 DTOapplication这一层是很关键的它负责接收 HTTP 请求、做参数校验、调用多个领域模块完成流程编排。它可以直接依赖自己需要的多个领域模块但领域模块之间不允许随意互相依赖必须通过接口或事件来协作。这一层通常没有业务规则只有流程组织和适配逻辑。support里放的是完全通用、不归属任何业务领域的东西。很多人容易在这里犯错误把看起来“公共”但实际带着业务语义的类也塞进来。判断标准很简单你问“这个类如果删掉会不会影响某个领域的业务表达”如果不会它才足够通用。3.2 选哪一个领域先拆多模块结构好画难的是从现有代码里找到拆分的下手点。我建议按两个维度来筛试点业务变化的频率和依赖关系的复杂度。优先拆的是那些“业务频繁变化、依赖纠缠相对少”的模块。因为业务频繁变化意味着当前结构对团队的阻碍最大而依赖纠缠少意味着拆分成本相对低容易在短时间内看到效果建立团队对模块化的信心。如果业务变化快但依赖复杂可以先做“逻辑隔离”在同一个工程里新建独立的包结构先把代码搬进去等后续依赖理清了再提升为独立模块。这不是偷懒而是控制风险。从依赖复杂度低但变化也少的模块开始做也可以因为难度低、不会出错但收益感不强团队容易产生“费这么大劲也没啥用”的感觉。所以综合下来最优试点是“高变化 中等复杂度”的模块。在我经历的项目里最容易拆出成果的是查询链路。报表、后台列表这类查询往往涉及多张表 join而且需求经常变今天要加一个筛选条件明天要加一个统计维度。这类功能非常适合先做轻量级 CQRS 拆分——把查询逻辑从原来的命令逻辑里剥离出来。具体操作就是在订单模块里增加一个OrderQueryService把所有只读查询集中到它下面查询时不做任何写操作原有的增删改走OrderCommandService。两者内部可以共用领域模型但 Service 层面完全分离。这个动作看起来简单却能把“改查询”和“改命令”两类变更隔离开大幅度降低互相影响的概率。3.3 领域模块内部的包结构领域模块内部也需要分层。我常用的结构是这样的order-domain/ ├── api/ # 对外暴露的接口与 DTO │ ├── OrderCommandService.java │ ├── OrderQueryService.java │ └── dto/ ├── application/ # 应用服务负责用例编排与事务边界 │ └── OrderAppService.java ├── domain/ # 领域层业务模型与核心规则 │ ├── model/ # 实体、值对象 │ ├── repository/ # 仓储接口 │ └── event/ # 领域事件定义 └── infrastructure/ # 基础设施实现持久化与外部调用 ├── persistence/ # MyBatis/JPA 实现 └── external/ # 调用其他模块的防腐层实现api包里的接口就是本模块暴露给外界的窗口别的模块只允许依赖这一层绝对不能越过它去碰domain和infrastructure。domain包里的模型是纯内存状态的业务对象不关心数据库不关心 HTTP 协议。infrastructure负责把数据库表和外部接口适配到领域模型上来。这样分层后有个明显好处换数据库、换缓存、换外部接口实现都不动领域模型只换infrastructure层。你可以在单元测试里用轻量的内存实现替代数据库测试速度快到起飞。3.4 事务边界和依赖方向控制模块拆分后最容易翻车的是事务控制。过去单体代码里一个方法可以把订单表、库存表、支付流水表放到一个事务里跨库也好同库也好反正都在同一个服务方法里提交。拆成独立模块后如果还想象过去一样在订单模块的操作里直接拿到库存模块的事务那就把模块边界给破坏了。我的做法是把事务边界控制在应用服务方法级别也就是OrderAppService的每一个公开方法对应一个完整的业务用例在这个方法上标注事务注解。领域内部的仓储方法不单独开事务即便某个内部方法自身产生了数据修改也跟随外层应用服务统一提交。库存模块数据的修改必须由库存模块的应用服务自己开启事务不能由订单模块代替它操作库存表。如果链路里有跨模块的强一致需求就到了要重新审视设计的节点要么把两个操作合并为一个领域内的用例要么接受最终一致性。依赖方向必须由工具强制检查不能靠口头约定。我在构建流程里埋了一层架构测试扫描所有模块的依赖关系一旦出现反向依赖构建直接失败。这块后面在第 5 章节展开但它不是可选项而是模块化能否长期持续的保障。4. 拆分过程中的常见问题与排查经验4.1 循环依赖拆完发现两个模块互相调用领域拆分经常遇到的第一座山就是循环依赖。订单模块想调用库存模块的扣减接口库存模块又因为某个统计需求反过来查询订单模块的数据结果两个模块互相引用Maven 编译直接报错。循环依赖能不能靠调整代码结构解决不要急着上框架先回到领域思考这两个模块是不是真的需要互相知道对方如果只是查询需求可以考虑把查询数据通过事件或物化视图冗余过来如果订单模块仅仅是需要一个“库存是否充足”的判定那这个判定逻辑其实可以下沉到订单模块自己维护一份必要的数据快照或者由库存模块以事件方式持续推送库存摘要。如果循环依赖来自“两个模块都依赖了对方的一个领域对象”那通常说明这个对象切错了归属。正确的处理方式是引入一个更低层的模块来存放两者都认可的通用概念比如订单模块和支付模块都需要“金额”这个概念就可以把它下沉到支持层或者直接放进某一方的api层供对方依赖。下沉之后循环自然解开。我踩过的坑是想用依赖注入框架的延迟加载技巧绕过循环依赖。具体做法是在 Spring 里给一对互相引用的 Bean 加Lazy注解让容器延迟初始化这样编译能通过、启动也不报错但运行时一旦某一个 Bean 在初始化阶段真的调用了对方就会遇到意想不到的代理问题。那类代码不但难读控制依赖方向的能力也基本丧失了。原则是不要绕要重新切分归属。4.2 过度设计拆得越细不一定越好模块化这条路上另一个常见的极端是拆得上头。我一个同事负责的模块只有四五百行代码他非要拆出五个 Maven 模块每个模块下还设三层结果大部分模块里只有一个接口和两个实现类团队协作时切来切去上下文不断跳转效率比拆分前还低。合理的模块粒度没有一个精确的公式但我有一个简单的经验值一个领域的代码量如果低于两三千行且未来半年看不到显著增长那它不应该成为独立模块。可以先作为某个模块内部的包存在等业务跑起来、边界确认清晰了再提升。拆出七八个一级模块一般来说足够支撑一个中型业务系统了如果超过十五个你要警惕是不是把人造概念当成了领域。识别过度设计也有一些信号频繁在不需要跨模块的地方强行发事件为了让两个领域看起来协同工作而编写大量无意义的编排代码模块之间调用极少大部分时间每个模块都在孤岛里自嗨。出现这些信号说明拆分的依据不是真实协作需求而是想象中的结构美感。架构是为业务服务的不是为了画一张漂亮的模块图。4.3 数据一致性与事务的取舍跨领域模块之后原本单体里一个事务能搞定的事情被拆成了多段数据一致性就成了绕不开的话题。强一致和最终一致之间怎么选我总结了一个比较简单的判断口径如果用户操作后必须立刻看到结果且失败率不高那么尽量合并在一个领域内完成用本地事务保证一致性如果涉及外部系统或长链路或者失败后的补偿逻辑清晰就接受最终一致用消息和补偿任务来兜底。最典型的例子是下单减库存。传统方案里订单创建和库存扣减放在同一个事务数据库压力大并发高时锁冲突非常明显。模块化之后我们可以让订单创建完成立即返回“已受理”同时发出订单已创建事件库存模块异步响应扣减。如果库存不足再通过消息反向通知订单模块进入失败补偿状态。这套逻辑在离线场景跑得很稳但有一个前置条件是补偿流程必须被重视。很多团队上线了异步链路却忘记了补偿设计一旦库存扣减失败用户的订单就永远卡在中间态。我在实际项目中专门加了一张事件消息记录表记录每个异步动作的状态并配套定时扫描补偿任务把漏掉的处理补回来。处理原则是宁可补偿流程复杂也不要让业务卡死。5. 模块化后的协作与治理组件化的隐性收益5.1 接口契约先行避免内部能力被滥用领域模块暴露出去的api层就是组件对外的“插座”它的稳定性直接决定了整个系统的协作顺畅度。接口在完成之前就要定好约定尤其要明确哪些字段是必填、哪些值域允许出现、失败时的异常如何表达。我习惯把对外接口的 DTO 从领域内部模型里彻底分离。接口返回的字段名、类型和层级是为调用方设计的不属于任何内部计算逻辑。即使内部模型重构接口承诺不变调用方就无需感知变化。这个约束需要在代码审核里持续维护任何人不得在接口层直接暴露内部实体。另一个容易忽略的点是权限边界。模块化的应用层可以同时服务内网、后台和开放接口但不同终端的接口契约未必相同。一个字段内部运营想看不代表要暴露给对外的接口。所以不要把“接口”理解成 HTTP 入口而要理解成“能力暴露面”每一个暴露面都要有意识地做一次裁剪。5.2 代码归属的自动化守护靠人治维持模块边界是不可靠的必须把约束写进构建流程。我这里分享两个轻量级的做法。第一个是用 CI 的依赖检查插件。Maven 环境下可以用依赖分析插件在每次构建时检测模块间是否有非预期依赖发现直接报错。这种方式比较粗糙但能最快拦截明显的非法依赖。第二个是运行时架构测试。如果团队使用 JavaArchUnit是一个非常好用的架构断言库。它可以直接对代码里的包依赖进行静态分析我经历过一个真实的落地配置是这样写的AnalyzeClasses(packages com.example.order) public class OrderArchitectureTest { ArchTest static final ArchRule apiLayerRule layeredArchitecture() .consideringAllDependencies() .layer(api).definedBy(..api..) .layer(domain).definedBy(..domain..) .layer(infrastructure).definedBy(..infrastructure..) .whereLayer(api).mayNotBeAccessedByAnyLayer() .as(api 层是模块的门面不允许上层反过来依赖内部实现); }这样一个测试放在每个领域模块的测试目录里持续集成阶段自动运行任何人试图跨依赖都会在测试阶段被打回。它不需要架构组天天盯着代码评审而是让规则成了系统的一部分这比写一墙设计文档都管用。5.3 团队协作方式的转变模块化之后我发现团队协作的方式也会跟着变化。以前一个需求下来订单组、库存组、支付组的人要在同一个分支上改代码合并冲突频繁发布时所有人必须守在电脑前。拆成独立模块后各模块的 CI 流水线可以独立构建和独立验证跨模块的联调用契约测试来保证开发节奏一下子轻快了很多。权限管理也可以恰到好处通过代码仓库的 OWNERS 文件把模块目录的修改权限分配给对应负责人别的模块的改动不允许直接合入主分支。这样的治理在单体代码时代很难落地因为一个改接口字段的需求会波及所有模块的文件。当然模块化不是银弹。它解决的是结构演进的问题并不能替代业务建模的思考。如果你不清楚业务的真实变化点和协作链即便拆成微服务拆成几十个模块也只会把混乱分散到更多地方而已。要说个人体会的话这个演进过程里最费力气的不是设计出那几张模块图而是说服团队在刚到两个模块、收益还没显现的时候坚持把依赖检查做下去。很多人会提出“现在挺快的别折腾了”但只要方向对越往后你会越感谢当初那个强制边界的决定。而且你会发现前端组件化和后端模块化走到最后完全是一套语言先划边界再定契约然后内部随意折腾外部纹丝不动。对后端开发者来说学会用领域拆分去组织工程结构其实就是在服务端世界里做组件化设计。