从零开始构建Java后端:项目结构设计要点 📅 2026/8/9 6:11:40 一个后端项目从零开始最容易犯的错误不是代码写错而是把时间浪费在“重新发明轮子”的结构折腾上。你打开IDE新建一个Spring Boot工程默认生成的目录只有几个空壳包然后你开始凭感觉往里塞类Controller放一堆Service放一堆Entity放一堆Mapper放一堆。三个月后项目还能跑但没人敢动——改一个订单逻辑要翻五个文件加一个字段要改七处地方。项目结构不是文件夹的摆放美学而是团队协作的契约、业务演化的边界、以及技术债的防火墙。真正值得从零设计的不是“包名怎么起”而是“依赖方向怎么定”。如果所有类都能互相调用那这个项目就是一张没有中心的蜘蛛网任何一次重构都会牵动全身。后端的项目结构本质上是对“依赖倒置原则”的物理化表达。你希望业务核心不依赖框架细节你就得把领域模型、应用服务、基础设施明确分层你希望接口稳定你就得把DTO和Entity隔离开你希望测试容易你就得让依赖边界朝着“外部依赖可替换”的方向收敛。下面这套结构设计思路不是唯一答案但它是经过大量生产项目验证的可行起点。先定顶层边界模块化比分层更迫切很多人一谈结构就想到Controller、Service、Mapper这三层这其实是“贫血模型”时代的惯性。现代后端面对的是多端适配、消息队列、定时任务、外部API集成单纯的纵向分层会让每个横切关注点散落各处。更值得优先考虑的是横向的模块化切分把系统拆成独立的业务模块每个模块内部再去分层。比如一个电商后端可以拆成order、product、user、payment、inventory等模块每个模块有自己对外暴露的接口和内部实现。模块化的核心价值是“变更局部化”。改订单模块的数据库表不该影响支付模块的编译升级用户模块的缓存策略不该让商品模块重新部署。实现这种隔离未必需要微服务单进程内的多模块工程比如Maven多模块或Gradle多项目就能做到。模块之间只能通过明确定义的API交互禁止跨模块直接访问对方的Mapper或Entity。如果两个模块需要共享某些领域对象抽到common模块或者独立出依赖包但绝不能图省事互相引用。在顶层边界上我强烈建议把“外部依赖适配器”也当成一个模块来看待。比如数据库访问、Redis操作、消息队列发送、第三方HTTP调用这些都属于基础设施。把它们集中放在一个叫infrastructure或adapter的模块里业务模块只定义接口由基础设施模块实现。这样一来你的业务代码永远不会出现“直接new一个RestTemplate”或“直接写死Redis key”的情况。业务模块的代码只依赖抽象接口具体是MySQL还是PostgreSQL是Redis还是本地缓存全部在启动装配时决定。包结构的分层逻辑从依赖方向而不是目录名称出发模块划好了接下来是每个模块内部的包结构。网上的模板都是controller/service/mapper/entity照着写确实简单但往往导致Service变成“万能中转站”既要处理事务又要做参数校验还要转换DTO偶尔还塞点业务规则。这个问题的根源在于分层只考虑了技术角色没有考虑职责变化频率。我推荐一种更稳健的内部分层方式按“领域、应用、基础设施、接口”四个层次组织包每个层的命名和依赖方向是唯一的。domain领域层存放实体、值对象、领域服务、仓储接口。这一层不依赖Spring不依赖任何框架注解除了必要的JPA注解可以权衡但更推荐纯POJO。application应用层存放用例Use Case、命令/查询对象、DTO转换器。这一层负责编排领域服务处理事务边界但具体业务规则不写在这里。infrastructure基础设施层实现仓储接口、调用外部服务、配置消息监听。这里可以依赖Spring可以写RedisTemplate可以访问第三方SDK。interfaces接口层存放Controller、WebSocket端点、消息监听器如果消息作为外部输入。这一层只负责协议解析、参数绑定、返回格式封装不允许出现业务逻辑。依赖方向是单向的interfaces 依赖 application 和 domainapplication 依赖 domaininfrastructure 实现 domain 定义的接口但 domain 不依赖任何其它层。这种结构下即使你哪天把Spring换掉或者把REST风格改成gRPCdomain和application几乎不用动。为了维持这个方向你要刻意禁止跨层调用比如Controller直接调用Repository或者application层直接访问Redis在代码审查时直接打回。Controller的瘦身协议层只做三件事很多项目的Controller动辄几百行里面塞满了输入校验、参数组装、日志打印、异常捕获。这不是Controller的错是你把所有职责都堆在了一个方法里。Controller应该薄得像一张纸只负责三件事接收HTTP请求、解析并适配参数、调用一个应用服务方法、把结果转成响应格式。除此之外什么都不做。为了达成这个目标你需要注意几个细节。第一Controller方法的入参不要直接使用Entity或领域对象应该定义独立的Request DTO。这样做不仅是为了安全也是因为HTTP的传参格式比如JSON字段命名方式、日期格式和领域模型往往不一致。第二Controller方法不要写try-catch全局异常处理器会统一处理你只需要抛出业务异常即可。第三Controller方法的返回值统一用ResponseEntity或自定义的Result包装但包装逻辑别写在Controller里放一个ResponseMapper类来做。另外一个很容易被忽视的点是Controller的路径设计不是简单的URL拼接而是API契约的一部分。如果你从零开始务必先设计一套资源命名规范比如/rorders/{orderId}而不是/order/queryByOrderId用HTTP动词表语义——POST创建、PUT全量更新、PATCH部分更新、DELETE删除、GET查询。别小看这些约定它决定了你的接口是否容易被前端、第三方、以及未来的你自己理解。Service的粒度一个用例一个方法而不是一个实体一个类传统项目喜欢按实体创建ServiceOrderService、UserService、ProductService。然后每个Service里塞一堆方法createOrder、cancelOrder、payOrder、queryOrderDetail、queryOrderList……看起来挺整齐但业务稍微复杂一点就会出问题下单同时要扣库存、发优惠券、记录积分这些操作分布在OrderService、InventoryService、CouponService里到底谁调用谁事务边界要跨越几个Service更合理的做法是按业务用例来组织Service方法甚至直接用一整个类表示一个用例。比如下单是一个用例定义在CreateOrderUseCase类里取消订单是另一个用例定义在CancelOrderUseCase里。每个用例只关注自己的输入、输出、前置条件和后置条件。这样可以避免Service变成“所有操作的集合”。如果团队成员多强制要求每个方法不超过50行因为用例级别的服务方法逻辑一定是清晰的序列检查状态、调用领域规则、持久化、发布事件。在应用层服务中我还建议引入“命令对象”Command作为输入。比如CreateOrderCommand包含用户ID、商品ID、数量、地址等字段应用服务接收这个命令对象而不是接收一串独立的参数。这样做的好处是参数的增删改不会导致方法签名频繁变化同时也便于做参数校验可以直接在Command里用JSR-303注解。命令对象是应用层与接口层的边界契约它比DTO更承载语义。Entity和DTO别让领域模型裸奔到接口层实体Entity是业务的核心资产它的生命周期应该由领域层管理而不是被Controller直接序列化输出。如果你直接把User实体当成响应体返回就会出现几个问题密码字段被JSON序列化出去了懒加载的关联关系在序列化时触发N1查询实体内部的业务方法被外部调用方绕过。把Entity封装在领域层内部对外暴露通过DTO是隔离变化的最基本手段。DTO的转换应该发生在应用层或接口层而不是写在实体内部。你可以用MapStruct、BeanCopier这样工具来简化转换但要注意领域对象和DTO的字段类型不必一一对应DTO应该是为当前接口场景“量身定做”的视图。比如用户详情接口可能需要返回用户拥有的角色列表但Entity里可能只有角色ID集合。这种情况下你需要在应用层把角色ID批量查询后组装成一个UserDetailDTO而不是直接从Entity映射。我见过有些团队为了省事直接在Entity上加了大量的JsonProperty和JsonIgnore注解试图让实体同时充当序列化对象。这在项目初期很爽但一旦接口需要的格式和实体结构冲突比如需要把orderNo和orderType拼接成一个字符串你就会发现实体上全是面向输出的逻辑。记住实体是行为的载体不是数据的展示层。任何与存储和展示相关的细节都不应该污染实体定义。依赖注入的边界构造器注入禁止字段注入从零搭建项目时很多人习惯在Service里写Autowired或者Resource加字段注入。代码确实简洁但带来了三个隐患依赖关系不透明、无法用于构造不可变对象、便于写测试时被Mock。请在项目规范里白纸黑字写上所有依赖注入一律使用构造器方式。Spring Boot的构造器注入只需要一个final字段加一个构造函数Lombok的RequiredArgsConstructor可以帮你省掉样板代码。构造器注入的好处是当某个Service的依赖超过4个时IDE会直接给出构造器太长的坏味道你就有机会反思是不是当前类的职责过重。同时构造器注入天然支持不可变对象避免某个依赖在运行期间被替换这种替换往往导致诡异的bug。还有一个边界是禁止在业务代码里使用ApplicationContext.getBean()来获取对象。这种“服务定位器”模式会绕过依赖注入流程让你的代码无法从容器中独立运行。如果你发现自己需要从Spring容器里手动拿Bean可能意味着你的设计出现了循环依赖或者依赖方向反了。配置管理把配置从代码中剥离但不要过度崇尚配置中心项目里配置文件最容易失控。一开始只有application.yml后来加了application-dev.yml、application-prod.yml然后还有bootstrap.yml接着引入Nacos或者Apollo配置文件里塞满了开关、超时、并发数、第三方AK/SK……配置管理的核心原则是配置是代码的一部分但必须与环境分离。不要用if-else根据环境变量去切换逻辑而应该通过ConfigurationProperties绑定配置类让代码与配置之间有一个强类型的安全网。在项目结构上我建议把每个模块的配置类放在各自模块的infrastructure/config包下而不是全部堆在启动类的扫描路径里。比如Redis配置类放在infrastructure/redis下MongoDB配置类放在infrastructure/mongo下。这样做的好处是当你移除一个模块时对应的配置也会一起消失不会留下死配置。对于敏感信息密码、密钥从项目第一天就避免明文写在配置里使用环境变量或密钥管理系统。这不一定上什么重量级系统本地开发用~/.env文件生产用K8s的Secret就能解决。配置的默认值应该保证本地开发能直接启动而不是要求每个新同事都要去配一套数据库和Redis。这也是项目结构的隐性质量指标一个新成员克隆代码后能不能在10分钟内跑起来。异常处理结构让业务异常和系统异常各归其位项目结构里异常处理是一个经常被忽略的“隐藏层”。很多人直接把异常打印堆栈然后返回空响应或者一律返回500导致前端一脸懵。一个合格的后端项目应该定义一套统一的异常模型包括错误码、错误消息、HTTP状态码、可选的错误详情。从零开始你就该规划好异常类的层次结构。我的建议是在application层定义BusinessException业务异常和SystemException系统异常业务异常携带错误码和参数化的消息模板系统异常保留原始异常链。然后在接口层写一个全局异常处理器把异常转换成对应的HTTP响应体。在domain层不要定义任何和框架相关的异常只使用领域自己的校验异常并由应用层捕获后统一转成BusinessException。这种结构让业务代码可以放心地“直接抛异常”而不用在每层都写try-catch。同时你还可以在异常处理器里记录一条包含traceId的日志并把traceId返回给前端。一个实用的细节是所有对外返回的错误消息不要用技术术语用面向用户的自然语言。数据库连接超时和“服务繁忙请稍后重试”给用户的感受是完全不同的。测试目录与结构把测试当成二等公民的项目注定烂尾很多人从零搭建项目时完全不考虑测试目录的存在直到要写demo示例才想起来加一个test包。项目结构里测试代码和主代码一样需要精心设计。基础要求是主代码在src/main/java下测试代码在src/test/java下包名保持一一对应。更进阶的要求是为不同层次的测试划分独立的目录和命名空间。比如domain下的测试类使用纯JUnit不需要Spring上下文跑得飞快infrastructure下的测试类可以启动Spring但要用DataJpaTest或MybatisTest这种切片测试不要整个上下文启动interfaces下的测试类用WebMvcTestmock掉所有应用层服务。如果你的测试类命名有规律比如OrderServiceTest、OrderControllerTest、OrderRepositoryIntegrationTest团队成员一看就知道该往哪里补充测试。结构设计对测试的影响还体现在依赖注入上。如果一个Service使用构造器注入写测试时直接new一个Service实例传入mock依赖的桩对象即可。如果用了字段注入测试时还得借助Spring和ReflectionTestUtils效率低一个等级。这一点也是强制构造器注入的直接收益。模块间通信共享模型与依赖包的设计在模块化工程中模块间通信的信息载体怎么定义是直接把对方的Entity拿来用还是抽象出共享DTO我强烈建议模块之间只能依赖对方的“接口模块”比如一个单独的API模块而不是实现模块。举例来说订单模块要查询用户信息设计一个user-api模块里面只定义UserQueryService接口和UserSummaryDTO。订单模块只依赖user-api用户模块在user-impl中实现该接口。启动时通过Spring Boot自动装配把实现注册进去运行时订单模块调用的永远只是接口。这种“接口与实现分离”的结构好处非常明显编译时依赖最轻、测试时可轻松替换为Mock实现、模块版本演进时不会产生偏包依赖。代价是需要多建几个模块多写几行接口定义但这笔投资非常值得。如果一个项目小到不需要模块化那就更简单了——你只需要在包名上体现一样的边界但依然要遵循“依赖方向单向”的原则。从零开始构建后端项目真正要交付的不是一个能跑的demo而是一个能让团队持续叠加功能而不失控的脚手架。项目结构不是一个一次性的设计产物它是一个随着团队认知和业务复杂度演进的生命体。每当你发现自己因为某个模块太大而需要拆分或者发现依赖关系变得混乱那就是重构结构的信号。本文提到的这些要点没有一条是教条——它们都指向同一个原则让你谈业务的时候不被技术细节打断让你改技术方案的时候不牵扯业务逻辑。把这一点想透了目录怎么分其实已经不重要了。