
有个面试场景我特别喜欢用来摸候选人的底问一句“你现在这个Spring Boot项目的包结构长什么样为什么长这样”十个里面有六七个会愣住然后背出controller/service/mapper三层再补一句“反正都是这么建的”。说实话这不能全怪候选人因为大量教程把项目结构当成了脚手架自动生成的一部分大家只学会了“用”没想过“为什么”。项目结构这件事往小了说是包目录怎么摆、类怎么放往大了说就是整个团队的协作边界、依赖治理、编译部署策略甚至面试时怎么展示你对架构的理解。这篇内容我会从Spring Boot默认结构的运行机制讲起聊到什么时候该从单模块拆成多模块、模块边界怎么划、框架层代码怎么下沉到私库、再结合Redis Stream这种实际业务场景看项目结构到底怎么支撑功能开发和部署运维。适合正在搭新项目的朋友、被现有项目结构折磨的开发以及准备面试的人。1. 五个收藏夹里的答案不如一个真实项目的目录现场1.1 面试官问“项目结构”到底想问什么热搜词里“spring boot面试题”常年在线面试官问项目结构表面上是看你知不知道那几层目录实际上想看的是三件事。第一你有没有从“类”的视角上升到“模块依赖”的视角。Controller调Service、Service调Mapper谁都会写但Controller能不能直接调Mapper如果业务简单很多人的回答是“偶尔调一下没毛病”这就是典型缺乏边界意识的信号。项目结构不是摆好看的每一个包、每一个模块都是一道依赖约束越界一次未来重构就多一分成本。第二你分不分辨得出“结构设计”和“框架约定”的区别。Spring Boot默认会扫描启动类所在包及子包这个扫描机制决定了包路径组织不是纯主观的。很多人不知道这点项目一复杂就乱挪目录结果启动报错找不到Bean还以为是被什么神秘力量影响。第三你会不会根据业务规模和团队规模去调整结构。3个人写一个管理后台和三四十个人写一个多端中台结构方案不可能一样。面试官想听的不是一个标准答案而是你的判断框架。所以这篇内容里我不打算给一张“照着抄就完事”的标准结构图那没有意义。我给的是一套思考方式看到目录的时候你该注意什么设计目录的时候你该先问自己什么。1.2 默认结构背后不是目录是运行时契约Spring Boot默认脚手架生成的结构是这个样子com.example.project ├── ProjectApplication.java ├── controller ├── service │ └── impl ├── mapper ├── entity ├── vo └── dto看着很普通对吧。但这个目录不是随便排的它背后站着三类运行时契约。第一类是组件扫描契约。SpringBootApplication默认扫描的是它所在包及所有子包这意味着一开始就确定一个根路径之后所有能被Spring托管的组件都得放在这个根路径下面。你一旦把某个业务模块挪到根路径外面又不做额外的scanBasePackages配置启动的时候就会莫名其妙少一堆Bean。这是新手最常见的问题之一也是项目结构调整时最容易被忽略的坑。第二类是资源目录契约。resources目录下static放静态资源、templates放模板页面、然后你可以建mapper目录放MyBatis的XML映射文件配置文件放在根下。这些位置有些是约定好的有些是通过spring.mvc.static-path-pattern之类的配置项指定的。项目结构一乱首先出问题的往往不是Java代码而是静态资源404、配置没生效这些看起来无关的故障。第三类是自动装配契约。Spring Boot的starter机制就是靠META-INF/spring.factories或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件来注册自动配置类的。这个机制在后面讲框架层下沉到私库时会非常重要——你以为把公共代码打包成jar别人引进来就自动生效了实际上漏掉这个文件引进来跟没引一样。我见过太多人把项目结构的问题理解成“目录好不好看”其实结构问题本质上是契约问题、边界问题、编译依赖问题。目录只是这些约束的物理形态。2. 单模块不是原罪但复制粘贴出现时就得思考拆分了2.1 拆模块前先看看这四个信号很多团队一上来就照着网上的教程把项目拆成gateway、system、framework一堆模块拆完发现开发效率反而更低了改一个字段要连续发布好几个模块本地起服务也慢。拆模块这件事时机很重要。我自己总结出四个信号满足两条以上就该认真考虑多模块了。第一个信号是Git提交冲突变多。几个人在同一个模块里加代码每次合并都要处理一堆冲突说明这个模块太大大家在同一个文件系统里抢地盘。第二个信号是新同事上手太慢。新来的人要花两三天才能说清楚“前端传过来的一个下单请求完整跑一遍经过哪些类”说明流程链路太长结构分层没有起到引导作用。第三个信号是“复制粘贴式复用”。A项目里封装好了一个工具类或者统一返回结构B项目要用大家不约而同地复制过去。过了一个月两个项目的实现开始分叉Bug在一个项目里修了另一个项目还是坏的。第四个信号是持续集成越来越慢。单模块项目一旦膨胀每编译一次全量构建CI跑一次十分钟起步这时候拆分模块不仅是为了边界清晰也是在帮CI减负。如果你只有一两个信号先别急着拆模块。拆模块有成本依赖管理、版本升级、本地联调这些都会变复杂。但是信号一旦出现越拖代价越大因为单模块膨胀到一定程度后拆分的难度就不是线性的是几何级的。2.2 多模块的依赖拓扑先想清楚方向再动手常见的Spring Boot多模块结构依赖方向大概是这样bff-web (Controller层只做参数接收、会话控制) │ biz-service (业务编排事务管理领域规则) │ dal-mapper (数据访问MyBatis的Mapper与XML) │ base-common (无业务语义的通用工具、基础类)还有一组横切模块比如api-dto、remote-client专门放对外暴露的接口定义和DTO其他模块都可以依赖它们但api-dto不能反过来依赖任何业务模块。依赖方向的铁律就是每一层只能往下依赖业务模块绝对不能反过来依赖web层。为什么这条铁律这么重要因为依赖方向一旦乱掉最直接的影响就是编译耦合。A模块的改动会逼着B、C、D模块一起重新编译越到后面越容易变成“摸哪哪疼”。而且依赖混乱的项目代码的可测试性也会崩——你想给Service层写单测发现它依赖了Controller层的类那你还得先把整个Web容器跑起来。我见过一些项目结构上写了三层实际上Controller直接调Mapper、Service里new一个Controller的类去获取登录态这样的结构有跟没有一样。另一个容易忽略的点是依赖关系要体现业务语义。有些人拆模块是按“技术层”拆的一个web模块、一个service模块、一个dao模块所有业务都混在里面。这种方式对简单项目没问题但当一个模块里同时塞了下单、支付、库存、用户四块业务它又变成一个大泥球只是包了一层新壳。所以更推荐的做法是优先按业务域聚合比如order-center、user-center、payment-center每个业务中心内部自己再去分controller、service、mapper层。这样拆的好处是一次需求改动往往只影响一个业务中心跨模块联调范围小新人也容易定位代码。2.3 Maven多模块改造时的几个实操细节理论上聊完具体到工程操作有几个细节非常容易踩坑。第一是父POM的dependencyManagement。父POM里用dependencyManagement统一管理版本号子模块里引入依赖时不需要再写版本。这样可以保证所有模块用的Spring Boot版本、第三方库版本是同一个避免升级依赖时出现A模块升了、B模块没升的诡异问题。需要注意dependencyManagement只是统一管理版本并不是子模块声明依赖后自动引入子模块还是得自己写dependency坐标。第二是Spring Boot插件只在需要打成可执行Jar的模块里配置。通常只有最外层的启动模块比如bff-web模块才需要配置spring-boot-maven-plugin。其他被依赖的模块如果也打了这个插件启动时可能生成一堆无用的fat jar反而影响构建依赖关系。第三是模块间的依赖要用Maven坐标不要用相对路径的本地Jar包依赖。有些团队图省事在A模块里直接依赖B模块的target/xxx.jar这种依赖在团队协作中非常危险别人拉下来代码一编译就报“包不存在”。正确的做法是父工程里声明module子模块之间按artifactId互相依赖Maven构建时按依赖顺序自动完成编译。3. 模块职责、DTO归属以及common模块的自我修养3.1 每个模块该放什么、不该放什么多模块结构定下来之后最头疼的问题不是技术而是“这个类到底放哪个模块”。放错了后续每一行代码都在还债。拿一个电商系统举例典型的模块职责划分应该是这样user-center用户注册、登录、信息维护、权限基础数据。对外提供的核心接口是用户查询、用户状态变更。order-center订单创建、状态流转、超时处理。对外提供的核心接口是订单创建、订单查询。payment-center支付单、支付回调、退款。对外提供支付确认、退款等接口。api-dto或xxx-api各业务中心对外的DTO、接口定义不依赖具体实现。每个业务模块内部可以再分成controller、service、mapper、entity这些子包。但要注意这里的entity只能属于模块内部不允许直接作为跨模块接口的返回参数。跨模块传递数据用DTO。原因很简单entity往往直接映射数据库表结构数据库表加个字段entity跟着变跨模块调用方的代码就可能被破坏。DTO是模块对外的接口契约它有自己独立的生命周期可以随业务接口变化而调整。有人觉得这样太繁琐“我直接返回实体类多省事”。省事是省事但代价是模块之间彻底失去边界。除非你的项目就两个模块且永远不会再扩展否则我很建议一开始就养成DTO隔离的习惯。这不是秀操作这是给未来所有重构留一条退路。3.2 common模块为什么越改越像垃圾堆几乎每个多模块项目里都有一个“凡是不知道放哪的东西都往里丢”的common模块。我见过最夸张的common模块里面既有字符串工具类又有短信发送配置还放了几个业务用的枚举、微信支付的回调常量、活动开关的配置。这种common模块本质上是没有边界意识的项目垃圾桶。common模块的正确姿态是“无业务语义、只做技术支撑”。可以放这些统一返回结果ResultT、统一异常结构、基础的BaseEntity、通用工具类日期、字符串、集合转换、基础的配置属性类比如Redis连接参数、线程池参数。不可以放这些某个业务模块特有的常量、某个业务特有的工具方法、某个接口的专用DTO。实际操作中我会把一个项目里的通用部分再拆成两层。一层叫base-common放完全与技术栈绑定的通用能力任何模块引了都不怕引入业务包袱另一层叫xxx-framework负责统一框架配置比如全局异常处理器、统一日志切面、WebMvc配置等。business模块只依赖base-common不直接依赖xxx-framework里的自动配置类。这样后续做框架层下沉到私库的时候边界会非常清晰。3.3 DTO、VO、Entity之间怎么摆关于DTO、VO、Entity有一个经典提问“Controller里能不能直接返回Entity”。如果项目规模很小只有一个后台管理端你直接返回Entity确实省事反正所有字段前端都展示得差不多。但项目一旦有App端、Web端、开放平台三套调用方每套需要的数据都不一样返回字段开始出现“某些端不能看到某个字段”的需求时Entity直出就是给自己挖坑。推荐的做法是Entity只存在对应业务模块的mapper层用于数据库映射VO放在Controller附近负责组装视图展示字段DTO放在对外API的定义处通常单独一个api模块负责跨模块、跨系统传输。分层结构完整的情况下一次请求的流转是这样Controller接收请求参数并转成DTO或Command对象Service处理业务后返回给Controller一个领域结果Controller再组装成VO返回前端。中间转换代码确实会多一些但是当你需要改一个接口返回结构而不影响数据库表结构时就知道值了。4. 框架层代码下沉到私库一次完整的整理复盘4.1 是什么逼着我们把框架层“外包”出去热搜词里有一条特别有意思“【框架调整】记录一次整理项目结构把框架层代码放到私库其他模块依赖jar包”。这个场景太真实了。公司做大之后手头三四个Spring Boot项目每个项目里都有几乎一模一样的代码统一的ResultT、全局异常处理器、日志切面、BaseEntity、Redis配置、短信客户端、支付回调验签逻辑。一开始是复制粘贴后来发现修一个Bug要改四个项目有一次忘跟其中一个同步线上出了事故。从那次之后我决定把这些公共的框架代码抽出来做成一个独立的工程发布到公司内部的Maven私库所有业务项目通过依赖坐标引用。这么做的收益是显著的所有项目框架逻辑版本一致修复一次全链路生效新项目起步时依赖坐标一加基础能力自动装配完成框架升级可以被单独测试后再发布不牵扯业务代码。代价是要多维护一个工程、多管一套版本发布流程。4.2 实操过程从抽代码到发布私库第一步新建一个普通的Maven工程名字推荐company-framework-starter或者xxx-spring-boot-starter。引入基础依赖比如spring-boot-autoconfigure、spring-boot-starter-web如果需要Web能力的话。这里要注意不能直接依赖spring-boot-starter-web的传递依赖最好用optionaltrue/optional标注或者用providedscope避免下游项目被强制引入你不需要的web容器。第二步把公共代码搬进来。包括统一的响应结构、异常处理、工具类、基础配置。搬的时候要克制只放那些跨项目一定用得到的东西拿不准的先不搬。搬过去之后把Configuration之类的配置类改成自动配置类或者单独定义配置入口。第三步注册自动配置。这一步是核心。在src/main/resources/META-INF下Spring Boot 2.7之前用spring.factories内容长这样org.springframework.boot.autoconfigure.EnableAutoConfiguration\ com.company.framework.autoconfigure.FrameworkAutoConfigurationSpring Boot 2.7之后官方推荐用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports直接每行写一个自动配置类全限定名。写完后引用了这个jar的项目启动时框架配置才会被自动加载。漏了这一步或者文件路径写错你打包出来的jar跟普通工具包没区别配置类永远不生效而且你不知道它为什么没生效。第四步发布到私库。准备好Nexus或Artifactory后在pom.xml里配好distributionManagementdistributionManagement repository idinternal-releases/id urlhttps://nexus.internal.company.com/repository/maven-releases//url /repository /distributionManagement然后执行mvn clean deploy等一分钟jar就进私库了。业务项目的pom里加上依赖坐标启动验证。如果私库需要认证在settings.xml里配置server的账号密码这里不展开。4.3 踩过的坑每一个都值得记住这个过程中我踩过好几个坑逐个说。坑一自动配置类没有被扫描。第一次抽取时我只把配置类放进jar里没有注册到spring.factories结果下游项目启动后框架的拦截器全没生效。排查方式要感谢Spring Boot的启动日志开启debug之后能看到哪些自动配置被匹配、哪些被排除。之后就记住了跨jar的配置类必须走自动配置注册机制不能指望组件扫描扫到jar包外面的包。坑二依赖传递导致启动卡死或版本冲突。框架jar里如果直接依赖了具体的中间件客户端比如某个特定版本的Redis客户端下游项目又引入了另一个版本轻则启动告警重则运行期方法找不到。解决办法是框架层对这类依赖尽量声明为optional或provided让各个业务项目自己控制版本。框架层只提供默认实现和配置项不强制绑定版本。坑三配置属性命名随意。框架里有一堆自定义配置项比如短信开关、日志级别。我没给它们加统一前缀结果业务项目里配置的时候东一个sms.enabled西一个company.log.level找起来非常痛苦。后来把所有配置项收敛到统一前缀下面比如company.framework.sms.enabled并定义好ConfigurationProperties的prefixConfigurationProperties(prefix company.framework.sms) public class SmsProperties { private boolean enabled; private String accessKey; private String secretKey; // getter/setter... }这样配置管理看起来像一份清晰的清单而不是散落在各处的孤儿键。坑四版本管理与兼容性。框架jar一旦被多个项目使用升级就不能随心所欲了。我现在维护的做法是框架版本跟随主版本走比如2.6.x的框架jar只兼容Spring Boot 2.6.x的项目升级框架必须发版本公告注明依赖变更点。这样虽然多了一步流程但至少不会再出现“明明把框架升级了结果某个项目启动直接报错”的情况。5. 从Redis Stream拉取队列消息看项目结构如何承载业务5.1 一个常见问题背后的结构困境热搜词里有一个“spring boot redis stream 如何拉取队列消息”非常能代表一类问题很多人遇到具体业务需求时不知道代码该往哪里放。Redis Stream作为轻量级消息队列比Kafka、RabbitMQ轻很多很适合在Spring Boot项目里处理异步任务、顺序消息、延迟处理。但问题是项目结构如果没有设计好你会发现在Controller里监听消息、在Service里写消费循环、在配置类里new一个容器……代码散得乱七八糟。拿一个实际场景举例用户下单后需要发送一个“订单创建成功”的短信通知。这个通知不需要同步返回所以选择投递到Redis Stream由一个消费者异步拉取并发送。在这个场景里项目结构至少要承载三个职责消息投递、消息拉取、消息处理。这三件事的代码既不能全塞在Controller里也不能全部写进Service的业务方法中。5.2 Stream消息消费者的搭建与代码落位在Spring Boot中拉取Redis Stream消息有两个主流姿势。一个是注解驱动的StreamListener一个是编程式的StreamMessageListenerContainer。注解方式写起来最简洁适合快速实现编程式适合需要精细控制并发、消费组、ACK策略的场景。我推荐用编程式容器管理原因有两个第一它可以独立配置线程池大小和轮询频率不用依赖Spring默认的线程模型第二它的整个生命周期可以被自动配置类统一管理不会散落在业务逻辑里。核心的代码结构分三块。第一块在framework或infra模块里定义Stream消息的统一消费容器配置Configuration public class StreamConsumerConfig { Bean public StreamMessageListenerContainerString, MapRecordString, Object, Object streamContainer( RedisConnectionFactory connectionFactory, StreamMessageListenerContainerOptionsBuilder optionsBuilder) { StreamMessageListenerContainerOptionsString, MapRecordString, Object, Object options StreamMessageListenerContainerOptions.builder() .pollTimeout(Duration.ofSeconds(2)) .batchSize(10) .executor(Executors.newFixedThreadPool(4)) .targetType(Map.class) .build(); return StreamMessageListenerContainer.create(connectionFactory, options); } }第二块在某个业务模块里定义具体的监听器实现StreamListener接口。监听器只做三件事接收消息、反序列化、转交给业务服务。代码要短错误处理要干净Component public class OrderNotifyListener implements StreamListenerString, MapRecordString, Object, Object { private final NotifyService notifyService; public OrderNotifyListener(NotifyService notifyService) { this.notifyService notifyService; } Override public void onMessage(MapRecordString, Object, Object message) { String stream message.getStream(); RecordId recordId message.getId(); MapObject, Object body message.getValue(); // 解析body中的订单号、手机号等字段 try { notifyService.sendOrderCreatedNotice(orderId, phone); // 手动确认 stringRedisTemplate.opsForStream().acknowledge(stream, consumerGroup, recordId); } catch (Exception e) { // 记录日志不ack,让消息留在Pending列表里便于后续排查 log.error(order notify failed, stream{}, recordId{}, stream, recordId, e); } } }第三块在启动或配置阶段注册监听器指定消费组和从哪个位置开始读container.receive( Consumer.from(order-notify-group, instance-1), StreamOffset.create(order:notify, ReadOffset.lastConsumed()), orderNotifyListener); container.start();这三块代码放在不同的模块容器配置放在框架层监听器放在业务模块业务服务放在该业务模块的service包下。结构很清晰也方便测试——单测时只需要mock掉通知服务监听逻辑可以独立验证。5.3 消息处理失败时项目结构怎么兜底消息处理最怕的是“一看日志都成功了但业务没生效”。Redis Stream有一个Pending Entries List也就是消费了但没有ack的消息列表。上面代码里故意不ack就是为了保留失败现场。从结构设计上失败兜底可以做三件事。第一监听器里catch异常后不要默默吞掉至少把消息内容原样打到日志里方便手工补单和排查。第二设计一个专门的重试处理器从Pending列表里重新拉取消息并再次投递给业务Service。第三对于重试多次仍然失败的消息投递到一个order:notify:dead的Stream里由专门的Dead Letter消费者做人工介入或者补偿处理。实际写的时候我不建议在监听器内部写复杂的重试循环。更好的做法是保持监听器尽量薄把重试策略交给框架层或独立的组件。这个设计思路和项目结构是一个道理每个类有每个类的职责每个模块有每个模块的边界组合起来才不容易烂。6. 运行、部署和中间件对项目结构提出的隐藏要求6.1 开发环境命令行运行结构混乱的第一个受害者热搜词里“spring boot项目 开发环境命令行运行项目”看起来是个很基础的操作但项目结构设计不合理的时候命令行跑项目会变成灾难。比如多模块项目里你必须在启动模块的目录下执行mvn spring-boot:run如果模块依赖关系不对构建顺序一乱启动都会慢很多。更常见的是本地开发时不同环境有不同的配置。用一个简单的命令行方式启动mvn spring-boot:run -Dspring-boot.run.profilesdev或者打成jar之后运行java -jar order-center.jar --spring.profiles.activedev这两条命令能跑通前提是项目结构里配置文件位置清晰、模块依赖不出问题。在实际项目里我遇到过最典型的命令行启动失败就是启动模块依赖了某个没install到本地仓库的兄弟模块mvn spring-boot:run直接报“Artifact xxx was not found”。解决办法是在父工程目录先执行一次mvn install把公共模块装进本地仓库。这个过程不复杂但每次新同事加入都要犯一次同样的错所以现在我会在项目文档里把启动顺序写死先mvn install再mvn spring-boot:run。6.2 Tomcat部署时的结构改动比想象中多Spring Boot默认用内嵌Tomcat打出来的Jar包直接java -jar就能跑。但在一些公司运维体系强制要求用外部Tomcat部署这就逼着项目结构上做两处适应。第一pom.xml的packaging要改成war同时把内嵌Tomcat依赖标记为provided让外部容器接管。第二启动类要继承SpringBootServletInitializer并重写configure方法SpringBootApplication public class OrderCenterApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { return application.sources(OrderCenterApplication.class); } public static void main(String[] args) { SpringApplication.run(OrderCenterApplication.class, args); } }这里有一个很多人忽略的点改成war部署后项目里的静态资源和自定义Filter、Listener的注册方式可能会受影响。如果你在项目里自己写了ServletContextInitializer要特别注意外部容器下它的执行顺序可能与内嵌模式不同。我建议除非运维真的强依赖外部Tomcat否则尽量保持Jar包部署少一个容器环境变量少一类问题。6.3 中间件部署时项目结构要为配置外置留出位置现在很多项目用Docker Compose编排依赖项Redis、MySQL、Nacos、Kafka。这些中间件部署与项目结构的关联点在于配置文件的可替换性决定了一个jar包能不能在不同环境间无缝迁移。我在项目里比较推荐的做法是application.yml只保留最基础的配置比如应用名然后把环境相关的配置放到外部。Spring Boot的命令行参数、环境变量、外部配置文件优先级都高于包内配置文件。部署时可以用环境变量覆盖中间件地址比如docker run -e SPRING_PROFILES_ACTIVEprod -e REDIS_HOST10.0.0.5 -e MYSQL_URLjdbc:mysql://... -p 8080:8080 order-center.jar配合项目结构我会在业务模块里统一用ConfigurationProperties读取配置属性而不是散落在一堆Value注解里。这样配置文件即使被外部覆盖代码也不需要重新编译。项目结构和配置外置是配套的结构清晰外部配置才能精准命中对应的属性类属性类收拢配置维护才不会乱。经过这次从默认目录到多模块拆分、从复制粘贴到框架层下沉私库、从单体应用到Stream消费者的完整梳理我对项目管理最深的体会是结构不是拿来炫耀的是拿来省事的。省编译的冲突、省上手的成本、省线上排查的精力。每个人都会经历“照着模板搭项目”的阶段但真正从项目结构里收获红利的往往是那些愿意在项目还小的时候就认真对待每一层边界、每一个模块职责的人。