从零开始构建Java后端:项目结构设计要点
2026/8/9 6:11:35 网站建设 项目流程

一个后端项目从零开始,最容易犯的错误不是代码写错,而是把时间浪费在“重新发明轮子”的结构折腾上。你打开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 和 domain,application 依赖 domain,infrastructure 实现 domain 定义的接口,但 domain 不依赖任何其它层。这种结构下,即使你哪天把Spring换掉,或者把REST风格改成gRPC,domain和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的粒度:一个用例一个方法,而不是一个实体一个类

传统项目喜欢按实体创建Service:OrderService、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序列化出去了,懒加载的关联关系在序列化时触发N+1查询,实体内部的业务方法被外部调用方绕过。把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下的测试类用@WebMvcTest,mock掉所有应用层服务。如果你的测试类命名有规律,比如OrderServiceTestOrderControllerTestOrderRepositoryIntegrationTest,团队成员一看就知道该往哪里补充测试。

结构设计对测试的影响还体现在依赖注入上。如果一个Service使用构造器注入,写测试时直接new一个Service实例,传入mock依赖的桩对象即可。如果用了字段注入,测试时还得借助Spring和ReflectionTestUtils,效率低一个等级。这一点也是强制构造器注入的直接收益。

模块间通信:共享模型与依赖包的设计

在模块化工程中,模块间通信的信息载体怎么定义?是直接把对方的Entity拿来用,还是抽象出共享DTO?我强烈建议:模块之间只能依赖对方的“接口模块”(比如一个单独的API模块),而不是实现模块。举例来说,订单模块要查询用户信息,设计一个user-api模块,里面只定义UserQueryService接口和UserSummaryDTO。订单模块只依赖user-api,用户模块在user-impl中实现该接口。启动时通过Spring Boot自动装配把实现注册进去,运行时订单模块调用的永远只是接口。

这种“接口与实现分离”的结构,好处非常明显:编译时依赖最轻、测试时可轻松替换为Mock实现、模块版本演进时不会产生偏包依赖。代价是需要多建几个模块,多写几行接口定义,但这笔投资非常值得。如果一个项目小到不需要模块化,那就更简单了——你只需要在包名上体现一样的边界,但依然要遵循“依赖方向单向”的原则。

从零开始构建后端项目,真正要交付的不是一个能跑的demo,而是一个能让团队持续叠加功能而不失控的脚手架。项目结构不是一个一次性的设计产物,它是一个随着团队认知和业务复杂度演进的生命体。每当你发现自己因为某个模块太大而需要拆分,或者发现依赖关系变得混乱,那就是重构结构的信号。本文提到的这些要点,没有一条是教条——它们都指向同一个原则:让你谈业务的时候不被技术细节打断,让你改技术方案的时候不牵扯业务逻辑。把这一点想透了,目录怎么分,其实已经不重要了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询