从零搭建SpringBoot应用:我的模块化设计笔记 我决定从零搭建一个SpringBoot应用时最先扔掉的是那些自动配置的幻觉。SpringBoot惯坏了太多人一个SpringBootApplication注解一个spring-boot-starter-web依赖项目就能跑起来。可一旦业务复杂到需要多人协作这种“乐高式”堆叠会变成一场灾难。模块化从来不是代码结构的装饰而是对抗熵增的唯一手段。这篇文章记录我这次重建的全部思考没有完美的答案只有不断取舍的过程。第一步反着来的依赖设计大多数人搭建SpringBoot应用习惯先写Controller再写Service最后写Mapper。我这次反着来先定义模块之间的边界再决定依赖关系。模块化最重要的不是拆分代码而是控制箭头方向。业务模块不能依赖其他业务模块的具体实现只能依赖抽象接口。为此我用Maven的多Module工程每个业务域独立成一个子模块模块之间通过接口和事件通信。这样做有一个明显代价初期开发速度变慢。为了调用另一个模块的方法你必须先定义一个接口再写一个实现类还要考虑包名隔离。但这笔钱必须掏。依赖关系的混乱是所有架构腐化的起点一旦某个模块直接import了另一个模块的内部类你们之间的耦合就已经埋下只不过测试还没爆炸而已。我在根pom.xml里锁死了所有公共依赖版本子模块里不再重复声明通用库只保留自己特有的依赖。模块划分的度不是越细越好我把应用拆成了六个子模块common、infrastructure、domain、application、adapter、bootstrap。熟悉DDD的人会看到洋葱架构的影子。但我不打算神化它们。模块化设计的目的是让失败的代价最小化而不是让划分本身成为艺术品。common只放无逻辑的常量、枚举、工具类严禁出现任何Spring注解。domain放纯业务实体与领域服务不依赖Spring不依赖数据库这样单元测试速度极快。adapter是唯一允许出现Controller的地方它负责接收HTTP请求、参数校验、响应封装。application服务协调领域模型与基础设施但它不直接操作数据库只通过接口。infrastructure实现那些接口包括MyBatis的Mapper、Redis客户端、消息队列生产者。bootstrap只有一个启动类负责装配。这套结构的好处是你可以随时把某个领域的实现从MyBatis换成JPA而不需要碰任何业务逻辑。我这次就真实做了一次这样的替换只改了一个子模块一百多个测试用例不动跑完全绿。配置的模块化把环境变量变成显式契约配置是模块化设计里最容易被忽略的雷区。很多人把所有配置写在application.yml里不同环境用不同的profile看起来没问题实际上配置项之间隐式依赖极多。我的原则是每个模块只能读取属于自己的配置前缀。比如user-service模块只允许读app.user.order-service只允许读app.order.。基础设施模块统一读取app.db.、app.redis.等。为了强制约束我写了一个启动时检查器遍历所有ConfigurationProperties类确认它们的前缀匹配当前模块名。违反规则直接启动失败。这很粗暴但有效。配置文件的丢失或错位往往比代码Bug更致命因为它不报错只会让你在半夜查线上数据不一致。我还把所有密钥从配置里剥离只保留环境变量占位符并使用Value(${DB_PASSWORD:})这样带有默认空值的写法让本地开发不用配真实密码但生产环境会因缺失而拒绝启动。领域模型不掺和Spring核心领域对象的生命周期里不需要任何Component或者Service。我的User类是一个纯粹的POJO带有changeEmail、activate这样具有业务含义的方法。贫血模型的痛我受够了一堆getter/setter的类让业务规则散落在Service里最终Service变成上帝类。这次我坚持让状态变更走领域方法比如changeEmail内部校验新邮箱不是空的并且与旧邮箱不同否则抛出自定义异常。这个异常也定义在domain模块里不是基础设施层的BizException。为什么不统一因为每个领域模块应该有自己独立的错误语义统一异常类会让你在catch时无法区分是校验失败还是并发冲突。后续我在adapter层写了一个全局异常处理器把领域异常翻译成不同HTTP状态码但翻译逻辑绝不出现在领域层。这样做的效果很明显单元测试不需要启动Spring上下文一个User对象就是一坨纯净的逻辑测试跑起来毫秒级。跨模块通信事件替代接口调用在内部模块需要协作时我优先选择事件驱动而不是直接方法调用。比如订单创建后需要扣减库存传统做法是OrderAppService里注入StockAppService然后同步调用。这个做法在单体应用里还行但会让模块间的依赖关系变成一团乱麻。我的设计是订单模块发布OrderCreatedEvent库存模块监听该事件并执行扣减。事件是模块之间最廉价的通信协议它让发布者不关心谁在听也不关心监听者是否失败。这里我不引入消息队列直接使用Spring的事件机制因为单体应用内同步发布够用。如果未来要拆微服务把事件模型换成MQ消息几乎零成本。我还要强调事件里的数据要尽量精简只放业务ID和必要的结果别把整个实体序列化进去否则领域模型就被绑死了JSON结构。基础设施层的口袋接口infrastructure模块很特殊它什么都能依赖但谁都不能依赖它。在application模块里定义接口在infrastructure模块里写实现这是依赖倒置的核心。以用户存储为例我定义了UserRepository接口方法签名是OptionalUser findById(Long id)没有任何数据库痕迹。实现类是MyBatisUserRepository里面是Mapper接口和XML查询。有人会问这不就是多包一层吗意义在哪意义在于你可以替换实现而不影响政策。我写单元测试时会用一个InMemoryUserRepository里面放一个HashMap测试就能飞快运行。没有数据库没有Spring没有Mock。代价是需要写一个测试替身类但这点成本换来了极大的自由度。当某个底层组件升级出问题时你甚至可以先退回旧实现。测试模块化的金字塔把测试写进对应的模块里而不是全部堆在src/test。我的domain模块只做纯逻辑测试没有Mock的测试才是可信的测试。application模块测试业务编排Mock仓库接口不碰数据库。adapter模块测试Controller用WebMvcTest切片Mock应用服务。infrastructure模块测试数据库映射用MybatisTest并启动H2。这套金字塔跑起来极快整个项目两百多个测试全跑完不到两分钟。其中80%的测试集中在domain和application它们不依赖真实基础设施。我遇到过很多团队测试跑一遍要五十分钟最后大家干脆不跑测试只靠代码审查。测试速度决定测试习惯而测试习惯决定代码成活率。所以我在构建脚本里规定了分层测试命令提交代码前只跑核心测试每晚跑完整套。启动速度与装配魔法SpringBoot的自动配置是双刃剑。为了模块化我关闭了大量默认自动配置采用显式装配。在bootstrap模块的启动类上使用Import逐个导入需要的配置类而不是ComponentScan乱扫。扫描包越广启动越慢依赖越隐晦。我只扫描bootstrap本身的包其他模块的组件通过配置类显式引入。比如用户模块的Web配置我在adapter包里写了一个UserWebConfig用Bean注册需要的Controller实例。这让我在启动时知道依赖清单也让IDE能精确分析引用。SpringBoot的spring.factories自动配置我基本不碰只有数据库连接池和Jackson这些无脑配置才保留。启动时间从原来的十秒降到两秒这对本地开发的反馈循环非常重要。踩坑记录循环依赖和Proxy陷阱模块化设计的第一个坑就是循环依赖。在重构初期order模块想调用user模块而user模块又需要订单数据结果Maven编译直接报循环。解决循环依赖的唯一办法是打破依赖方向而不是用Lazy注解糊弄。我最终决定把“用户最近订单数”这个查询下沉到order模块通过事件把计数结果推给用户模块而不是让用户模块直接查订单表。另一个坑是Spring的事务与模块化冲突。我习惯在application服务方法上写Transactional当方法内部发布事件时监听器默认在同一个事务里执行。这听起来不错但实际上事件监听器里抛异常会导致主事务回滚而某些场景我们恰恰希望“主操作成功旁路操作独立”。事务边界必须显式设计不能依赖Spring的默认传播机制。我在事件监听器上加了Transactional(propagation Propagation.REQUIRES_NEW)让旁路逻辑不受影响但这意味着如果旁路失败数据可能不一致。所以更稳妥的做法是把旁路操作写成可补偿的流程而不是盲目靠事务兜底。模块化文档即代码最后我写了一个脚本扫描各个模块的依赖关系生成一张依赖图。用Maven插件maven-dependency-plugin分析各模块之间的import任何违反规则的依赖都会在CI阶段报警。架构规则不写成文档而写成测试这比一百遍宣讲都有用。我还在每个模块的package-info.java里写清楚这个模块的职责边界、允许依赖的方向、禁止出现的API。这个过程的副作用是新成员入职后不用读长篇架构文档只需要看package-info和依赖检查脚本就能知道该怎么修改代码。模块化的终极目标是让错误在编译期或CI期暴露而不是在Code Review时靠人肉辩论。即使这个项目最终膨胀到几十万行只要边界还在我就能放心地在其中一个模块里重构而不用瑟瑟发抖。从零搭建SpringBoot应用最难的从来不是写代码而是抵抗“先把功能跑起来再说”的诱惑。模块化带来的每一分麻烦都在为未来的你减少十分混乱。这份笔记只是起点下一次我会继续探索事件风暴与模块化设计的结合毕竟架构不是一次成型而是一场没有终点的博弈。