
刚转 Spring 的同事抱着一堆代码问我你们的项目目录是自己设计的还是有模板说实话这个问题几乎每个人在接手一个新的 Spring 项目时都会问一遍。今天就把 Spring 项目目录结构这件事从底层原理到工程实践拆开讲回答目录到底该怎么摆、为什么这么摆、摆错了会出什么事。这篇文章面向正在学 Spring Boot 的同学也适合准备做多模块拆分、微服务改造的团队看完可以直接套用到自己的工程里。1. 目录结构不是抄的是长出来的1.1 从三层架构说起Controller-Service-Mapper 为什么是约定俗成几乎所有的 Spring 项目不管外包项目、自研系统还是开源商城包结构里都离不开这几个兄弟com.example.project ├── controller ├── service │ └── impl ├── dao / mapper ├── entity / model / domain ├── config └── utils / common这套结构的底层逻辑是经典三层架构表现层、业务层、数据访问层。Controller 只做参数接收和响应转换Service 承载业务规则和事务边界Mapper 负责跟数据库打交道。依赖方向必须是 Controller - Service - Mapper从上往下单向调用。这里有一个关键为什么要这么做的问题为了控制复杂度。如果你把 SQL 写在 Controller 里第一你会发现 Service 的事务注解失效第二同一个查询可能在十个接口里被复制十遍后面改一个字段名要全局搜索第三代码评审的时候根本没法看。三层架构把请求处理和数据操作之间的所有业务判断集中在 Service 层这是让系统可维护的最基本约束。Spring Boot 的包扫描机制也强化了这个约定标注了 SpringBootApplication 的启动类默认会扫描它所在包及其子包下所有标注了 Component、Service、Repository、Controller 的类。所以包名前缀必须统一启动类必须放在包层级的最顶层否则com.example.project.controller下面的类根本不会被扫描进容器。我见过不少人把启动类放在com.example而业务写在com.other结果启动时 Spring 静默跳过直到访问接口报 404 才发现 Bean 没注册。这种问题定位起来相当费时间因为 Spring Boot 启动日志里不一定有明显的报错。1.2 按技术分层和按业务模块两种包风格怎么选上面说的 controller/service/mapper 是按技术维度分层。还一种玩法是按业务模块切包比如一个电商项目com.example.mall ├── user │ ├── controller │ ├── service │ └── mapper ├── order │ ├── controller │ ├── service │ └── mapper └── product ├── controller ├── service └── mapper两种风格不是二选一而是层的粒度和归属问题。按技术分层适合小团队、单业务线的项目因为整个项目一眼能看完类也少直接平铺几乎没有理解成本。按业务模块适合业务复杂、模块边界清晰的系统比如商城、ERP、PaaS 平台。每个模块内部依然保持 controller/service/mapper 三层但模块之间互不感知。我个人的判断标准很简单如果这个项目未来要拆微服务从第一天就按业务模块包如果只是内部管理后台、或者两个人维护的小系统按技术层平铺更效率。按业务模块切的时候要特别注意包之间的循环依赖比如 order 的 Service 想调用 user 的 Service 是正常的但如果 user 的 Service 又反向依赖 order两个模块就纠缠在一起了后期拆微服务时全都得重构。2. Spring Boot 顶层目录启动类、资源与第三方接口放哪儿2.1 启动类的位置为什么必须是包根节点Spring Boot 工程的src/main/java和src/main/resources是两大主目录。Java 目录放源码resources 目录放配置文件、静态资源和模板。启动类的位置不是爱好问题而是ComponentScan的扫描根路径决定的。src/main/java/com/example/DemoApplication.java ✅ 推荐扫描 com.example 下所有包 src/main/java/com/example/project/DemoApplication.java ⚠️ 扫描 com.example.projectcontroller 如果放在 com.example.controller 会被漏掉启动类除了 SpringBootApplication通常还会带 MapperScan。有人说 MyBatis 的 Mapper 接口靠启动类扫描就行实际上 Mapper 接口是接口不是 Bean需要 MyBatis 的代理机制生成实现所以必须通过 MapperScan 指定 Mapper 接口所在的包比如MapperScan(com.example.project.mapper)。还有一个很容易忽略的目录细节MyBatis 的 XML 文件。公司里比较常见的做法是把 XML 和 Mapper 接口放在同一个包路径下Maven 构建时 XML 会自动被复制到 classpath。如果你的项目习惯把 XML 放到resources/mapper下那必须在application.yml里配置mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml这个配置不写启动时不会报错但运行时调用 Mapper 方法会直接抛Invalid bound statement (not found)。这是一个典型的目录放错位置导致的故障不是代码逻辑问题。2.2 给第三方提供的接口是独立服务还是放进业务模块这个问题在 Spring 社区问的频率很高要给第三方商户或外部平台提供 API应该单独建一个服务还是和内部接口放在同一个模块先说结论没有绝对答案取决于调用方和接口的规模。我最推荐的判断顺序是这样的如果只是给合作方提供一个两个接口且参数、鉴权和内部不太一样直接在对应业务模块里加一个独立的 controller 包对外路径统一比如/api/open/xxx配上独立的签名鉴权逻辑。如果对外接口数量超过 10 个、且未来会有独立的版本迭代从主工程里拆出一个open-api模块只放对外接口的 controller、DTO、service 抽象不直接引用内部实体。这样对外暴露面是收敛的。如果对接口的调用方非常多、并发量又高那就是完全的独立服务加上网关统一管理限流和鉴权。有一回我接手的一个项目把对外接口直接写在内部 UserController 旁边路径完全没有区分。第三方接入的同事只能靠问人来知道该调哪个路径后来 OpenAPI 文档一生成全是内部接口看了都头疼。后来我们把对外接口统一挪到独立模块加一层接口签名校验世界清净了。还有一个很少被注意到的点如果使用 Servlet 的 Filter 或 HandlerInterceptor 处理鉴权一定要通过配置类注册而不是靠 Component 自动扫描。因为 Spring Boot 自动扫描到的 Filter 会以不可控的顺序插入过滤器链一旦跟 Spring Security 的过滤器链路混在一起开关某个过滤器时会踩到各种诡异的问题。3. 多模块工程的目录设计依赖方向就是目录约束3.1 Maven 多模块的常见拆分方式单模块工程到一定规模后会自然走向多模块。多模块的目录设计核心不是模块越多越好而是依赖方向要清晰。一个比较通用的 Spring Boot 多模块长这样spring-project ├── pom.xml # 父 POM只做依赖管理 ├── spring-common # 工具类、统一返回体、常量、异常 ├── spring-domain # 实体类、领域对象 ├── spring-dal # MyBatis Mapper、XML、数据源相关 ├── spring-service # 业务逻辑层 ├── spring-web # Controller、拦截器、鉴权组件 └── spring-starter # 启动类、application.yml打包入口各模块的依赖方向是单向的starter 依赖其他所有模块web 依赖 serviceservice 依赖 daldal 依赖 domain 和 common。任何一条反向依赖都会把整个结构打回原形变成一团乱麻。使用 MyBatis 的多模块项目里有个细节Mapper 接口在 spring-dal 模块XML 也放 spring-dal 模块的resources/mapper下。启动类在 spring-starter 模块MapperScan扫描的是 spring-dal 里的包名这个扫描是可以跨模块的只要包名路径正确JVM 的 classpath 里能看到类就行。很多人会疑惑为什么还需要一个 spring-domain 模块如果把实体类直接放在 dal 模块web 模块的 Controller 如果要把实体返回给前端就得额外依赖 dal把数据访问层的细节暴露给了上层。抽一个 domain 模块三方都依赖它谁都不越界。3.2 common 模块的边界最容易翻车的位置多模块项目里翻车率最高的就是 common 模块。我见过一个项目common 模块里放了短信发送封装、支付回调逻辑、还有版本升级工具结果就是所有业务模块都依赖这个看起来什么都有的大杂烩。版本升级改了一个类的签名被影响的模块有五个。最后不得不花一个迭代拆分 common。common 模块的边界应该是无业务语义的字符串工具、日期工具、通用返回结果 Result 、基础异常、常量类。凡是带了业务含义的东西都不应该进 common。比如订单状态枚举就放到 order 模块里短信模板常量放到 message 模块里。很多团队图省事把公共业务逻辑往 common 塞短期开发快长期改造难。我常用的判断方法是把 common 想象成一把瑞士军刀任何模块都需要用但任何人都不应该在里面找到跟自己的业务相关的零件。如果发现 common 里的类需要 import 某个业务模块的包那就是错了。4. 微服务阶段目录从单库到领域拆分4.1 一个服务一个仓库还是一组服务一个仓库进入微服务阶段目录结构的问题变成了仓库粒度问题。两类做法第一类是一个服务一个 Git 仓库。优点隔离清晰服务之间的权限控制、发布流程都独立不同团队之间互不干扰。缺点是跨服务改接口时要在多个仓库之间反复切换、串行提 PR很痛苦。第二类是一个仓库多个服务类似 monorepo。所有服务放在一个工程目录下结构大概是microservices/ ├── gateway ├── auth-service ├── user-service ├── order-service └── pom.xml这类结构的好处是公共代码的管理成本低改动一个接口可以直接跨服务查看调用方代码评审也连贯。缺点是仓库体积变大、CI 触发频繁服务之间的代码耦合会顺着公共依赖悄悄蔓延。我见过不少中型团队因为怕微服务一定要拆仓库才选的单仓库最后发现公共配置的同步和接口变更效率反而高了很多。实际上服务拆分更适合从模块拆分开始在同一个工程里把模块边界和接口定义先画清楚等团队稳定、接口固定之后再按模块拆成独立仓库也不迟。项目目录结构永远是跟着团队状态走的技术决策不是单纯的代码洁癖。4.2 最常见的失败模式是按技术层拆服务很多同学在微服务改造时会下意识地把原来的 controller 全部放进一个 web 服务把 service 全部放进一个 business 服务把 mapper 全部丢进一个 data 服务。听上去像是对分层的微服务化实际上这是灾难。这样拆出来的每个服务都没有独立的业务边界web 服务里同时包含用户接口、订单接口、商品接口改一个订单逻辑要牵扯的 service 服务也要跟着发版两个服务之间的 RPC 调用非常密集响应时间暴增事务根本无法保证。正确的拆法一定是按业务领域拆服务领域内部再保持 controller/service/dal 三层microservices/ ├── user-service │ ├── controller │ ├── service │ └── mapper ├── order-service │ ├── controller │ ├── service │ └── mapper └── product-service ├── controller ├── service └── mapper每个服务是一个完整的纵向切片做到数据库独立、发布独立。这就是领域驱动设计里的限界上下文思想目录结构服务于业务边界而不是技术分层。Spring Cloud 环境下还有一个隐蔽的问题Feign Client 接口应该抽到一个独立的 API 模块或独立包中供调用方直接依赖避免调用方服务把服务接口类和实体类全量依赖进来。否则用户服务要调用订单服务的一个接口就得把整个订单实体和 Feign 配置都拉一遍耦合越来越重。5. Spring 生态演进对目录的新要求5.1 Spring Security 与自定义过滤器放哪儿很多项目用 Spring Security 做登录鉴权这时配置类放在哪里就特别关键。一般推荐统一放到config或者security包里核心内容是这样的SecurityConfig负责安全过滤链、放行规则、密码编码器自定义的 JWT 过滤器OncePerRequestFilter放在security.filter包里登录和鉴权的 Service 逻辑放在security.service里避免散落在一堆业务代码中这样做的好处是安全相关的代码是一个独立的横切关注点既不是用户业务也不属于商品业务单独收拢才能让安全审计的时候快速找到需要检查的类。如果把 JWT 过滤器写在auth模块的 controller 旁边后续改造 SSO 的时候会花很多时间在业务包里面翻箱倒柜。自定义 Filter 注册时建议通过SecurityConfig里的http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)来精确控制位置。如果只靠Component自动扫描注册Filter 的执行顺序不受控可能出现自定义过滤器在安全过滤链之前执行结果SecurityContext还没创建拿着校验完的用户信息也没地方放。5.2 Spring AI 接入后目录怎么排Spring AI 是这两年 Spring 生态比较受关注的新方向。接入 AI 能力对目录结构也有新的要求。通常会在项目里增加config/AiConfig初始化模型客户端、配置 API Key、超时参数、模型名称client/ModelClient封装对模型服务的调用屏蔽具体模型供应商的差异service/AgentService编排 Agent 的工作流比如结合 Dify 导出的流程转换后的 Spring AI 代码controller/ChatController业务侧对接交互入口这些内容如果直接散落到业务模块里业务代码会跟模型调用细节耦合得很深。更合理的做法是把 AI 能力封装成一个独立的ai-client模块只暴露两个方法sendMessage和sendMessageWithHistory。业务模块只关心对话结果完全不感知底层是哪个模型。项目里如果有多套 Agent 场景比如售前咨询 Agent 和售后工单 Agent我建议每个 Agent 是一个独立 Service 类放在同一个agent包下用策略模式来组织而不是写一个巨大的万能 AgentService。AI 代码的可维护性和传统代码一样目录结构如果不规划好后续模型的参数要迁移时改到想哭。5.3 从依赖方向看循环依赖怎么避免循环依赖是 Spring 项目老生常谈的问题。很多人以为解决循环依赖靠 Lazy但你仔细排查时会发现一个模块内部发生的循环引用通常不是两个 Bean 互相 new那么简单而是目录分层的依赖方向被打破了。比如 ServiceImpl 直接用了另一个模块的 Controller 类或者 Mapper 里面引用了 Service 对象这种代码一多包与包之间的依赖图就成了网状。Spring IoC 容器处理循环依赖是有条件的仅支持单例作用域而且依赖是通过属性注入的。如果是构造器注入的循环依赖容器直接抛BeanCurrentlyInCreationException。我维护过的一个项目两个业务模块互相调来调去最后只能把公共逻辑下沉抽出一个独立的领域服务包来打破循环。这件事给我一个教训目录分层不只是好看更是依赖方向的显式声明约束好依赖循环依赖天然就少。用 IDEA 打开工程后在模块上右键Analyze - Analyze Dependencies看一下依赖图如果箭头在图里画成了一张网那就是急需重构的信号。6. 目录排查速查与架构约束6.1 接口 404 和 Bean 找不到的排除顺序Spring 项目接口 404、启动后 Bean 不存在这几个问题百分之八十和目录放错有关。我整理了排查顺序直接照做看启动类所在包包名是否覆盖到了 controller/service 所在的所有包启动时是否加了MapperScan配置的包路径和 Mapper 接口实际路径是否一致MyBatis XML 是否在 classpath 里mapper-locations是否写对配置类是否被 Spring 扫描到比如你把一个Configuration类放在了工程之外的 jar 包里Controller 的类上是否有RestController或Controller还是只写了Component。有个很隐蔽的坑src/main/java下面如果有非 .java 文件Maven 默认不会把它打进 classpath。有人把 XML 放在src/main/java/com/example/mapper/里但没配置 resources 处理结果也是运行时找不到绑定。下面的速查表可以贴在项目 Wiki 里症状最可能的目录原因处理方式接口 404controller 不在扫描包范围内移动启动类到包根或调整扫描Bean 不存在MapperScan 路径和实际包不一致改成一致路径Invalid bound statementXML 不在 classpath配置 mapper-locations配置不生效配置类不在扫描范围检查包路径静态资源 404资源放在 classpath 之外放到 resources/static 下6.2 用 ArchUnit 把目录规则固化成自动化检查目录结构靠口头约定和 Code Review 是不够的人总会犯错。比较好的做法是用 ArchUnit 做架构约束的自动化测试把这些规则写进测试代码里违反就红牌。比如要约束 Controller 不能直接依赖 Mapper可以写AnalyzeClasses(packages com.example.project) public class ArchitectureTest { ArchTest static final ArchRule controller_should_not_depend_on_mapper noClasses() .that().resideInAPackage(..controller..) .should().dependOnClassesThat() .resideInAnyPackage(..mapper..); ArchTest static final ArchRule service_should_not_use_controller noClasses() .that().resideInAPackage(..service..) .should().dependOnClassesThat() .resideInAPackage(..controller..); }这种测试加的代价很小但收益是长期的——目录结构规范不再是一句口号而是每次构建都会自动执行的规则。我所在的项目组把这类检查加到 CI 之后评审里再也没出现过你为什么不按约定放目录的评论。6.3 几个看起来小但坑过很多人的路径问题还有几个低级的目录问题虽然不起眼但在真实项目中反复出现一是把静态资源放在src/main/java下的自定义包里Spring Boot 默认只会从classpath:/static、classpath:/public等位置提供静态资源你放包的目录下面浏览器访问必然 404。二是把application.yml放在项目的其他目录里因为启动时指定的 classpath 找不到配置文件Spring Boot 会按默认配置启动数据源、Redis 等自动配置可能全挂报错信息指向不明实际上就是配置文件路径不对。三是单元测试目录src/test/java的包名和主代码不一致。测试类对包内受保护方法的访问就失效了有的测试明明可以直接测 package-private 方法结果花了大把时间去走公共 API。目录结构这类问题平时不起眼但它决定了整个项目里每个人每天的工作路径。我自己带项目时最常用的判断标准是一个新同学第一次打开这个仓库看他能不能在五分钟内找到登录接口在哪个类里。如果能说明目录是合格的如果找不到就算代码写得再好这个项目也撑不到复杂的那一天。目录不是一成不变的它应该跟着项目一起成长。单模块先跑通业务量起来之后再多模块拆分业务边界清楚之后才考虑微服务的服务切分。每个阶段用合适的目录结构是最实际的做事方式。