Spring Boot多模块工程搭建:从IDEA新建Module到Maven依赖管理实战

发布时间:2026/10/3 14:57:48
Spring Boot多模块工程搭建:从IDEA新建Module到Maven依赖管理实战 从一个很常见的场景说开去。前段时间有个刚转 Java 的朋友在 IDEA 里建好了一个 Spring Boot 单模块项目然后跑来问我“我要把用户模块和订单模块拆开新建 module 的时候到底是选 Maven 还是 Gradle选 Spring Initializr 还是普通 Maven”我一看他已经在根项目里写了快两千行代码正准备手动拆分。这种“先把代码写肿再想着拆 module”的路径我见得太多了。老实讲新建 module 这个动作本身不难难的是理解 module 之间怎么协作、Maven 在中间干了什么、Spring Boot 在多模块工程里怎么定位。把这些想清楚新建 module 就是几分钟的事想不清楚后面等着你的就是编译不过、启动找不到类、依赖对不上三个经典的连环坑。这篇文章就是写给刚开始认真搞 Spring 工程的同学尤其是还在用 IntelliJ IDEA 做练习、想把项目拆成一个像样的多模块工程的人。我会从最基础的概念讲起然后带你在 IDEA 里完整操作一遍最后把那些很容易踩的坑摊开说。我在一线写过交易系统、后端服务、各种内部工具库日常基本都在跟 Spring 多模块工程打交道下面这些内容不是官方文档的复读而是踩过坑之后沉淀下来的做法。1. 先想清楚什么时候需要给 Spring 项目新建 module1.1 单模块项目在什么情况下会越来越难用刚开始学 Spring Boot直接在 start.spring.io 上勾选依赖生成一个项目写一个 Controller、配一个数据源这个阶段真的不需要 module。一个项目里就三五个类硬拆模块只是自找麻烦。但项目一旦真正开始承接业务几个问题就会很快冒出来某个工具类被好几个服务引用想改一个通用方法得全局搜索翻三四个地方逐个改。配置类、拦截器、公共实体在各个模块里复制粘贴后面要统一加一个字段时“Ctrl H”全局替换都救不了你。编译越来越慢。哪怕只是改了一个日志工具类整个大项目都要重新构建一次构建三四十秒起步耐心很容易被消磨掉。团队多人协作时一个分支改的东西经常跟另一个分支互相覆盖。代码之间没有边界大家只能靠口头约定“这块你别动”。出现这些症状说明你该在工程层面把代码拆开了。拆分不等于微服务架构你先要有一个朴素的理解新建 module 是在告诉构建工具“我要把这块代码独立管理保留自己的引用关系谁想用我就在 pom 里声明依赖”。Spring 技术栈里module 是最常见的物理边界后面做微服务的时候很多 module 又会演变成独立的服务但那是另一回事。1.2 典型的多模块划分方式多模块工程怎么分一般有两种主流思路。第一种是按技术层划分适合业务还不复杂、但代码已经很有规模的项目。比如一个后台管理项目可以拆成下面几层xxx-common通用工具类、常量、统一返回结构。xxx-dal数据库访问层放 MyBatis/JPA 的 Mapper、Entity。xxx-service业务逻辑层Service 接口和实现。xxx-webController 层统一接收 HTTP 请求。第二种是按业务域划分适合业务边界很清晰的项目。比如电商项目就拆成user-module、order-module、product-module每个模块内部再自己去分 Controller、Service、Mapper。这种拆分更接近微服务的感觉但初期模块之间的互相调用要慎重否则容易变成“高耦合模块”。我更推荐绝大多数学习者采用“技术层优先业务域后置”的方式。先学会怎么让 module 之间松耦合协作再考虑把业务切成一块块。很多新手一上来就把一个完整业务拆得七零八落结果一个订单功能要跨三个模块才能跑通调试起来非常痛苦。2. 动手之前必须理解的 Maven 模块机制2.1 parent pom 和 modules 节点的关系多模块工程的核心不是 IDEA 界面而是 Maven 的聚合机制。一个多模块工程根目录下会有一个 parent pom.xml里面用packagingpom/packaging声明它自己是个纯聚合工程不产生任何 jar/war 包。然后通过modules把子模块列出来packagingpom/packaging modules modulemy-common/module modulemy-service/module modulemy-web/module /modules每个子模块也有自己的 pom.xml它们通过parent指回根 pom这样整个工程就形成了一棵清晰的依赖树。parent groupIdcom.example/groupId artifactIdmy-parent/artifactId version1.0.0/version relativePath../pom.xml/relativePath /parent artifactIdmy-service/artifactId我刚开始学的时候最困惑的一点是relativePath要写什么它表示当前子模块的 pom 相对于父 pom 的路径。默认值是../pom.xml如果你的目录结构是“父工程根目录/子模块目录”那子模块的 pom 确实就在上一级的pom.xml所以 IDEA 自动生成后你常常不需要改这个值但最好要知道它是什么含义。用了它Maven 构建时就能直接在文件系统里找到父 pom而不是先去本地仓库找。2.2 Spring Boot parent 和业务 parent 怎么配合很多新手会在每个子模块里都写一遍parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.5/version /parent这是典型的错误。Spring Boot 的spring-boot-starter-parent是一个特殊的父 pom它管理了大量依赖版本比如 Spring、Jackson、Logback 等还配置了构建插件的默认行为。如果每个 module 都继承它一方面会让 spring-boot 相关依赖的版本在子模块之间无法统一治理另一方面你最终构建多个 module 时很可能会出现“同时依赖两个不同 Spring Boot 版本”的荒诞局面。正确做法是多模块工程的最外层父 pom 继承 Spring Boot 的 parent或者用dependencyManagement导入spring-boot-dependencies然后在父 pom 里统一管理各子模块的版本。子模块只需要继承你自己工程的父 pom依赖里写artifactId就够了版本号交给父 pom。以父 pom 继承 Spring Boot parent 为例parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.5/version relativePath/ /parent groupIdcom.example/groupId artifactIdmy-parent/artifactId version1.0.0/version packagingpom/packaging然后子模块里写dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies不用写version因为 Spring Boot parent 已经把版本管住了。如果你看到最近的 Spring Boot 3.2、3.3 工程这也是标准做法。理解了这套机制新建 module 就只是“填信息”而已心里会踏实很多。2.3 三种 packaging 类型到底怎么选新建 module 时IDEA 或 Maven archetype 会问到 packaging 类型。我直接给你结论pom父工程、聚合工程以及专门管理依赖清单的 BOM 模块。jar绝大多数业务模块Spring Boot 打可执行 jar 也是用这个。war老旧的外置 Tomcat 部署方式Spring Boot 内嵌 Tomcat 时基本用不上。所以你在 IDEA 里右键新建 Module选 Maven然后绝大多数子模块都保持默认 jar 就对了。只有最外层根 pom 需要手动改成 pom。这里不需要额外的 archetype选最普通的 Maven 模块就行后面依赖配置自己写。3. 在 IntelliJ IDEA 里新建 module 的完整实操3.1 先建一个不写代码的 Maven 父工程很多人一上来就在 IDEA 里随便新建一个普通工程然后在里面“新建 module”结果发现这个普通工程根本不是 Maven 工程parent pom 都没法写。我建议的起点是先手动建一个新的 Maven 空工程作为父工程。具体步骤打开 IDEAFile→New→Project左侧选Maven不要勾选任何模板。填好GroupId、ArtifactId比如com.example和my-parentVersion默认1.0.0即可。点Finish生成工程。此时它会自带src目录但你不需要它直接删掉。打开根 pom.xml把packaging从 jar 改成 pom然后按上面说的加modules空节点。在根工程上右键 →New→Module左侧选Maven输入my-common点 Finish。连续重复第五步把my-service、my-web都建出来。IDEA 会主动修改根 pom 的modules不需要你手填但你最好打开根 pom 确认一下因为有些老版本 IDEA 不会同步得非常及时。到这一步你已经完成了“新建 module”的第一层。也就是说IDEA 中的目录结构变成了my-parent ├── pom.xml ├── my-common │ ├── pom.xml │ └── src ├── my-service │ ├── pom.xml │ └── src └── my-web ├── pom.xml └── src看着这个结构你心里要有数根 pom 是个壳真正的代码全部在子模块里。3.2 为每个 module 补齐 parent 与依赖元信息新建出来的子模块 pom.xml 大概率长得很简单只有 artifactId 和 dependencies 空节点甚至没有 parent 节点。你需要手动改成这样?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdcom.example/groupId artifactIdmy-parent/artifactId version1.0.0/version /parent artifactIdmy-common/artifactId /project这里有个关于relativePath的细节IntelliJ 里新建的 module 默认会在生成的parent里自动加上../pom.xml这本来没问题。但如果你新建的 module 并不在根目录下而是嵌套在某个子目录里那就要注意路径层级必要时手动改为相对路径。大多数时候保持../pom.xml就好。3.3 设置模块依赖这才是新建 module 的核心目的现在我有了三个 module但它们彼此孤立工程根本称不上“多模块”。要让 module 协作就得配置依赖关系。典型场景是my-web依赖my-servicemy-service依赖my-common。在my-service的 pom.xml 里加dependencies dependency groupIdcom.example/groupId artifactIdmy-common/artifactId version${project.version}/version /dependency /dependencies在my-web的 pom.xml 里加dependencies dependency groupIdcom.example/groupId artifactIdmy-service/artifactId version${project.version}/version /dependency /dependencies这里用${project.version}是我推荐的做法。因为父 pom 的版本号升级时子模块之间依赖的版本号不用一个个改这算是用 Maven 属性管理版本的一个基础实践。加完依赖后不要急着写代码。先在 IDEA 右侧 Maven 面板里点击一下Reload All Maven Projects让依赖关系刷进 IDEA 的类路径里否则代码里 import 时会一片红。3.4 社区版与专业版的差异处理热词里有人问“IntelliJ IDEA 社区版怎么用 Spring Boot”这里顺便说清楚。IDEA 社区版功能上确实少了一些 Spring Initializr 的集成向导新建工程时没有Spring Initializr选项。但新建 Maven module 完全不受影响。我平时在家练习用的就是社区版操作路径一样先建 Maven 父工程再新建 Maven 子模块然后手动在 pom 里加 Spring Boot 相关依赖。或者你去 start.spring.io 下载一个 Spring Boot 初始工程把它作为父工程再往里加子模块也是可以的。区别只是少了一点自动生成多了一点手动配置反而能帮你加深理解。4. 新建完 module 后如何跑起来第一个 Spring Boot 接口4.1 启动类放在哪个 moduleBean 扫描边界怎么定义很多人在多模块项目里遇到的第一个真正的运行期问题是“日志显示启动成功但 Controller 404”。究其原因绝大多数是启动类位置放错了。Spring Boot 默认只会扫描启动类所在包及其子包。比如你把SpringBootApplication放在了com.example.web包里那com.example.service包里的Service可能根本不会被扫描注册。这里有两个办法第一个办法把所有业务模块的包名统一成同前缀。例如都用com.example.common、com.example.service、com.example.web然后启动类放在com.example.web.boot下这样 Spring Boot 默认扫描com.example三个模块的组件都能扫到。这是最推荐的做法。第二个办法在启动类上显式指定扫描包SpringBootApplication(scanBasePackages com.example) public class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); } }这么做虽然简单粗暴但要注意如果模块很多扫描整个根包可能会引入一些你不想托管的组件。更好的做法是结合ComponentScan的 includeFilters 或自定义注解不过初学者用统一包名最稳妥。启动类本身我建议放在最外层的 web 模块或者单独建一个xxx-boot模块。不要在好几个模块里各放一个带SpringBootApplication的类否则运行时容易遇到多个 DataSource 自动配置、多个 ApplicationContext 初始化之类的问题。4.2 多模块下的配置文件加载顺序单模块项目里application.yml放在src/main/resources下Spring Boot 会自动加载。多模块项目里情况就会变得有点微妙每个 module 都有src/main/resources到底读哪个我的实践经验是不要指望所有模块的 resources 目录都会被自动加载到 classpath 的根路径。如果你的启动模块是my-web它依赖了my-service那么my-service的src/main/resources/config下的配置文件并不一定会被当成 Spring Boot 的默认配置。Spring Boot 默认只会从启动模块的 classpath 根目录读取application.yml、application-{profile}.yml。想要加载其他模块的配置文件有几种方案把公共配置统一放在启动模块的resources下比如数据源配置、Redis 配置都由 web 模块负责加载。使用 Spring Boot 2.4 之后的spring.config.import语法在启动模块里显式导入其他路径的配置。使用配置中心比如 Nacos、Spring Cloud Config把配置完全从工程中抽出去。对于学习和前期项目我建议就用第一种方案简单直接。等后面项目大了配置多了再考虑配置中心。4.3 实战扩展用新建的 module 跑通一个 Spring Security 受保护接口新建了三个模块如果不做点功能验证你很难判断它是否真的“通了”。所以下面我带你做一个最小可验证的实践在my-web里加 Spring Security然后提供一个需要登录才能访问的接口。先在父 pom 的 dependencies 里加一个 Spring Boot 版本管理不用写 version 的依赖。注意我是在my-web模块的 pom 里加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency然后写一个最简单的配置类package com.example.web.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/api/public/**).permitAll() .anyRequest().authenticated() ) .httpBasic(); return http.build(); } }再在my-web里写两个接口RestController RequestMapping(/api) public class DemoController { GetMapping(/public/hello) public String publicHello() { return public hello; } GetMapping(/private/hello) public String privateHello() { return private hello; } }启动my-web后访问/api/public/hello会被放行访问/api/private/hello则会弹出登录框。默认用户名user密码在启动日志里是一个 UUID。这就完整验证了新建的 module 可以被 Spring Boot 正常启动、扫描、依赖而不仅仅是在 IDEA 里能编译通过。5. 新建 module 过程中最常见的几个报错5.1 Maven 依赖解析失败IDEA 能识别但命令行构建报错这个坑我踩过无数次。表现是IDEA 里面代码不报红、import 正常但执行mvn clean package时报“Could not resolve dependencies for project ...”。为什么会出现这种“看似正常、实则失败”的情况因为 IDEA 的编译机制和 Maven 构建机制不完全一致。IDEA 可以基于当前工程下的多个模块直接做模块间依赖解析但 Maven 命令式构建时如果一个模块 A 依赖了同一个工程里的模块 BMaven 需要先确保 B 被正确安装到本地仓库。如果你的 B 模块还没有执行过install或者 B 模块发生了重大变更却没有 installMaven 就会拿着旧坐标去本地仓库找找不到就报错。解决办法很直接mvn clean install -DskipTests在父工程根目录执行一次把整个工程所有模块都 install 到本地仓库。以后每次某个底层模块有改动至少要把改动的模块重新 install 一次。不要觉得这个步骤多余它恰恰是很多人第一次接触多模块工程时最容易忽略的一环。5.2 启动时 ClassNotFoundException 或 NoClassDefFoundError这类错误通常发生在你通过 IDEA 启动 Spring Boot 后发现某个来自 common 模块的工具类找不到了。出现的原因一般有两个。第一个原因是依赖范围问题。比如你在my-web中依赖my-service而my-service依赖my-common时用的是scopeprovided/scope那my-common的类不会传递到my-web的运行时 classpath。检查 pom 里有没有把provided乱用。第二个原因是打包插件配置问题。Spring Boot 多模块最终打包时通常只在可执行模块配置spring-boot-maven-plugin。如果每个模块都配了 plugin或者启动模块没有配 plugin那么打出来的 jar 可能不是 fat jar依赖的类自然不会带全。修正方式在启动模块my-web的 pom 里加build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build其余模块不需要配置这个插件保持默认打包即可。5.3 模块之间循环依赖和 Spring 三级缓存不是一回事有些人看到“循环依赖”就会激动地想到 Spring 三级缓存但那是两码事。模块层面如果my-web依赖my-service同时my-service又反过来依赖my-webMaven 在编译阶段就会直接失败因为两个模块互相等待对方先构建完成。这跟 Spring IoC 容器内的对象循环依赖完全不同。Spring 三级缓存解决的是“容器里两个 Bean 互相注入”的问题而 Maven 模块循环依赖无法靠 Spring 容器缓存解决只能从结构上打破。我的建议是依赖方向必须保持单向。比如common层不依赖任何业务层service层依赖commonweb层依赖service。如果业务上确实需要相互访问可以通过引入一个更底层的模块来承载公共接口或者把需要互相调用的部分下沉到common模块。这个意识从新建 module 的第一天就要建立否则后面越拆越乱。6. 我的几个实操习惯和建议6.1 模块命名一定要统一且可读很多同学建 module 时名字乱来module1、utils、demo-server过一个月自己都看不懂。我常用的命名规则是顶层project-name-parent通用能力project-name-common接口定义/DTOproject-name-api数据访问project-name-dal业务逻辑project-name-service启动层project-name-web或project-name-boot如果是业务模块就按照业务先分词比如user-service、order-service。模块名统一使用中划线不要用下划线因为 Java 包名和 Maven artifactId 习惯上保持一致更好。6.2 用 dependencyManagement 把版本管起来我见过太多项目每个模块的 pom 里写着一堆版本号还有像2.7.3、3.2.0这种打架的版本。多模块工程应该用好父 pom 的dependencyManagement。把所有的第三方依赖版本集中在父 pom 里声明子模块只写 artifactId这样升级依赖版本时只改一处。如果你做的是 Spring Boot 项目继承spring-boot-starter-parent后Spring 相关的依赖已经不用写版本了但其他第三方库建议还是手动管。每次升级依赖后在根目录跑一遍mvn dependency:tree看看完整的依赖树能帮你发现重复、冲突、旧版本的问题。6.3 新建 module 不是终点多模块只是起点我在实操中的体会是新建 module 本身没什么技术含量真正的门槛在于你如何看待“模块边界”。是依赖工具类就用 common是依赖接口就下沉接口是依赖领域就抽独立服务这是需要一点点积累的。一个项目拆得好不好不是看有多少个 module而是看修改一个功能时需要动几个 module。能尽量控制在两三个以内说明边界划得还可以。如果你后续打算往 Spring Cloud 微服务方向走那现在的多模块结构就是很好的底子。每个业务模块未来可以独立成微服务公共模块继续承担依赖治理。Spring AI 出现之后我还会习惯单独留一个ai-module放和大模型交互的客户端、提示词模板这也算多模块工程带来的便利。最后分享一个小技巧IDEA 里多模块工程改完 pom 后如果依赖一直刷新不出来或报一些奇怪的错误不要急着重启电脑先执行File→Invalidate Caches清理一下缓存再Reload All Maven Projects。大部分“新建 module 之后整个工程都崩了”的恐慌其实都是缓存和索引的问题项目本身往往是好的。希望这篇内容能让你下次点击 New Module 的时候心里更有底。