基于Spring Boot与MongoDB的整洁架构实践
目录 前提条件 1. 什么是整洁架构(Clean Architecture)? 2. 项目结构 3. 构建领域层 4. 构建应用层 5. 构建 MongoDB 适配器 6. 使用 Spring Boot 组装一切 结论
大多数 Spring Boot 教程会将所有内容紧密耦合在一起。控制器(Controller)调用服务(Service),服务调用存储库(Repository),而 @Document 和 @Field 等 MongoDB 注解则直接与业务逻辑混在一起。这种方式在初期可行,但当你需要更换数据库、在隔离环境中测试逻辑,或在不同上下文中重用领域规则时,就会遇到困难。
整洁架构(Clean Architecture)强制执行一条核心规则:源代码依赖始终指向内部。业务逻辑永远不会导入 Spring 或 MongoDB 的类。数据库被视为最外层的一个可插拔细节,即在不重写核心应用代码的情况下即可替换的组件。
在本文中,你将构建一个包含订单的产品目录系统。产品具有名称、价格和库存数量。订单引用产品,并强制执行“订购数量不能超过库存”等规则。该领域足够简洁,可以在一次练习中掌握,但它具备真正的业务规则,能够从这种架构中受益。技术栈包括 Java 17+、Spring Boot 3.x 和 Spring Data MongoDB。阅读完本文,你将获得一个领域层和应用层无需引入 Spring 或 MongoDB 类路径即可编译的项目结构。
完整源代码可在 GitHub 配套仓库 中找到。
前提条件
- Java 17 或更高版本
- Spring Boot 3.x(请使用 Spring Initializr 并添加
Spring Data MongoDB和Spring Web依赖) - MongoDB Atlas 集群(免费层级即可)。你可以按照 MongoDB Atlas 入门指南 进行设置。此外,本地 MongoDB 实例或 Docker 容器也可以。
- 对 Spring Boot 的基本了解(控制器、服务、依赖注入)
1. 什么是整洁架构(Clean Architecture)?
Robert C. Martin 引入整洁架构是为了让业务规则独立于框架、数据库和 UI。其核心思想是依赖规则:源代码依赖必须指向内部。内层定义接口,外层实现接口,绝不能反过来。
该架构通常被绘制为四个同心圆。每个圆代表一个层。依赖关系始终指向内部,从最外层的框架环指向最内层的领域环:
(图片链接:Clean Architecture 示意图)
从最内部开始:
领域层(Domain Layer):最内层,包含业务对象和规则。例如,Product 对象知道自己的价格,并验证库存不能为负;Order 对象知道它必须至少包含一个项目。这些都是普通的 Java 类,没有任何框架注解。
应用层(Application Layer):外一层,包含特定于应用逻辑的规则。例如“创建订单”就是一个用例。它协调领域对象,但不知道数据如何存储,也不关心用户如何触发该操作。
接口适配器层(Interface Adapter Layer):负责在用例使用的格式与外部代理提供的格式之间进行转换。控制器和 MongoDB 文档类位于此处。带有 @Document 注解的 ProductDocument 是适配器层关注的问题,而非领域层关注的问题。
框架与驱动层(Frameworks and Drivers):最外层,包含 Spring Boot、MongoDB 驱动程序和 Web 服务器。配置和组装在这里进行。@SpringBootApplication 就位于这一层。
MongoDB 与 Spring Boot 一起位于最外层。领域层定义了 ProductRepository 接口,在整洁架构术语中称为“端口”(Port)。端口只是一个 Java 接口,用于声明内层需要什么,而不说明它是如何提供的。外层的 MongoDB 适配器使用 Spring Data MongoDB 来实现该接口。如果明天你想用 PostgreSQL 替换 MongoDB,只需编写一个新的适配器即可,领域层和应用层完全无需修改。
在典型的 Spring Boot 应用中,实体类通常带有 @Document、@Id 和 @Field 注解。服务直接导入它,控制器又导入服务。每一层都知道 MongoDB 的存在。而在整洁架构中,实体是一个普通的 Java 类,而 @Document 类是适配器层中的独立对象,通过映射函数在两者之间进行转换。这种隔离意味着你的领域逻辑永远不会依赖于数据的存储方式。
2. 项目结构
该项目的包布局如下:
domain 包不导入 adapter 或 application。application 包导入 domain 但不导入 adapter。adapter 包同时导入两者,但依赖箭头始终指向内部。如果开发人员不小心在领域层导入了 MongoDB 类,这种包结构会立刻暴露出问题。
既然我们已经了解了端口的含义,目录名称就很容易理解了。in 端口定义外部世界可以要求应用做什么(用例);out 端口定义应用需要外部世界提供什么(例如存储库、外部服务)。
你可以克隆 配套仓库 来获取预构建的结构并进行学习。
3. 构建领域层
领域模型是没有任何框架依赖的普通 Java 类。首先是 Product:
构造函数验证价格是否为正数且库存不为负。decreaseStock 方法强制执行业务规则:不能订购超过现有库存的数量。此规则属于实体,而不属于服务类。如果库存验证逻辑放在服务中,你可能会通过代码库中其他地方直接调用实体来绕过它。
OrderItem 表示订单中的一行:
Order 类使用私有构造函数和公共 create 方法。这种模式(称为静态工厂方法)强制所有调用者通过 create 进行创建,以便我们能够强制执行始终保持成立的规则。在这种情况下,每个订单必须至少包含一个项目,且总价是根据这些项目计算出来的,而不是由调用者设置的。
List.copyOf(items) 创建了列表的不可变副本。如果没有它,传递原始列表的人可能会在订单创建后添加或删除项目,从而破坏总价计算。这种防御性拷贝是领域对象中的常见做法。
存储库端口接口定义了领域需要外部世界提供什么:
这些是位于 domain.port.out 包中的普通 Java 接口。没有 Spring 注解,没有 MongoRepository 扩展。如果你从 pom.xml 中删除 Spring 和 MongoDB 依赖,这个包仍然可以编译。这就是重点所在:领域层只依赖于 JDK,不依赖其他任何东西。
这对测试至关重要。你可以为 Product.decreaseStock() 或 Order.create() 编写单元测试,而无需启动 Spring 上下文或连接数据库。你还可以创建一个简单的内存版 ProductRepository 实现(将产品存储在 HashMap 中),并在不接触 MongoDB 的情况下测试服务。由于业务规则不依赖任何框架,因此它们可以在不同的基础设施之间移植。
4. 构建应用层
用例接口定义了外部世界可以要求应用做什么。这些是入站端口(Inbound Ports):
这里有几点需要说明。CreateOrderCommand 和 OrderItemRequest 是 Java Record。Record 是一种定义纯数据类的简洁方式。编写 record CreateOrderCommand(List<OrderItemRequest> items) 会自动为你提供构造函数、getter (items())、equals、hashCode 和 toString。你会在整个项目中看到 Record 被用于 DTO 和命令对象,因为它们是不带行为的数据载体。
为什么要将输入封装在命令对象中,而不是直接将 List<OrderItemRequest> 传递给 execute?因为如果用例以后需要更多上下文(例如客户 ID 或折扣码),你只需在 Record 中添加字段,而无需更改方法签名及所有调用者。命令对象为你提供了一个稳定的接口。
GetProductCatalogUseCase 更简单:它不接收输入并返回完整的产品列表。
服务实现通过端口接口协调领域对象:
对于命令中的每个项目,服务会查找产品、调用 product.decreaseStock() 以执行库存规则、构建 OrderItem 并保存更新后的产品。然后它从收集到的项目中创建 Order 并通过存储库端口进行持久化。UUID.randomUUID().toString() 为订单生成一个随机的唯一 ID。我们在应用层生成 ID 而不是让 MongoDB 分配,是因为领域不应该依赖数据库行为。
这里没有 @Service 或 @Autowired。这些是普通的 Java 类,通过构造函数接受接口。Spring 稍后会进行组装,但应用层不知道也不关心这一点。服务协调领域对象并调用存储库接口。它不包含业务规则(库存验证在 Product 实体中),也不关心数据是如何存储的(这些逻辑隐藏在端口接口之后)。
不过,这个服务有一个隐患:每个 productRepository.save() 调用都会立即提交。如果最后的 orderRepository.save() 失败,你将得到库存已减少但没有相应订单的情况。解决方法是使用数据库事务,在失败时回滚所有更改。但如果直接在 CreateOrderService 上添加 @Transactional,会将 Spring 导入引入应用层。相反,我们在适配器层使用一个精简的包装器来处理这个问题(见第 6 节)。
GetProductCatalogService 只做一件事:
OrderDocument 使用 @Document(collection = "orders") 进行注解,并包含一个 OrderItemDocument 对象列表。OrderItemDocument 是一个普通的类,由于它嵌入在订单文档中,因此没有使用 @Document 注解。
为什么要将文档类与领域模型分开?领域模型 Product 包含业务方法和验证逻辑,而 ProductDocument 仅用于 MongoDB 序列化存储数据。将两者混合会使领域模型与数据库模式(Schema)耦合。如果 MongoDB 模式发生变化(例如重命名某个字段或重构嵌套文档),领域模型依然保持不变,只需由映射器(Mapper)来处理这些差异。
映射器类通过静态方法处理转换:
这是简单的逐字段转换。对于这种规模的项目,不需要使用任何映射库。OrderMapper 遵循相同的模式,处理嵌套的 OrderItem 到 OrderItemDocument 的转换。
存储库实现(Repository implementations)将领域端口接口与 Spring Data MongoDB 连接起来:
MongoProductRepository 实现了领域的 ProductRepository 接口,并添加了 @Component 注解,以便 Spring 将其纳入依赖注入。在内部,它使用了 SpringDataMongoProductRepository,这是一个标准的 Spring Data 接口:
该接口扩展了 MongoRepository<ProductDocument, String>。这两个类型参数告诉 Spring Data 使用哪个文档类(ProductDocument)以及 @Id 字段的类型(String)。作为回报,你无需编写任何实现代码即可获得标准的 CRUD 方法(save、findById、findAll、delete)。Spring Data 会在运行时生成实现。适配器包装了这个生成的存储库,并使用映射器在文档对象和领域对象之间进行转换。MongoProductRepository 中的每个方法都遵循相同的“转换输入 -> 调用 Spring Data -> 转换输出”的三步模式。
MongoOrderRepository 对订单也遵循相同的模式。
如果你决定从 MongoDB 切换到 PostgreSQL,只需创建一个新的 adapter.out.persistence 包,其中包含 JPA 实体、JPA 存储库实现和新的映射器。domain 和 application 包将完全不需要改动。业务规则和用例逻辑保持原样。
6. 使用 Spring Boot 进行整合(Wiring)
REST 控制器是入站适配器。ProductController 暴露了 GET /products 接口:
OrderController 暴露了 POST /orders 接口,并将传入的 JSON 映射为 CreateOrderCommand:
底部的 @ExceptionHandler 会捕获领域验证(如 Product.decreaseStock())抛出的 IllegalArgumentException,并返回带有错误信息的 400 响应。如果没有这个处理,Spring 会返回一个带有堆栈跟踪的通用 500 错误。
请求和响应 DTO 定义为 adapter.in.web 包中的 Java record。CreateOrderRequest、OrderItemRequest、CreateOrderResponse 和 ProductResponse 都是不含逻辑的 record,仅作为 JSON 序列化的数据载体。控制器在这些 DTO 和领域对象之间进行映射。控制器从不直接接触 MongoDB 类。
还记得第 4 节提到的事务问题吗?CreateOrderService 逐个保存产品,如果最后的订单保存失败,库存已经减少但订单却未创建。我们需要使用 @Transactional,但将其直接添加到服务层会把 Spring 引入应用层。解决方案是在适配器层添加一个轻量级包装器:
TransactionalCreateOrderUseCase 与其他基础设施代码一起存在于 adapter.out.persistence 中。它实现了相同的 CreateOrderUseCase 接口,委托给 CreateOrderService,并添加了 @Transactional,以确保 execute 中的所有数据库写入要么全部成功,要么全部回滚。CreateOrderService 本身保持不受 Spring 注解的影响。
整合工作在一个带有显式 @Bean 方法的 @Configuration 类中完成:
MongoTransactionManager bean 告诉 Spring 如何管理 MongoDB 的事务。如果没有它,@Transactional 将不起作用。请注意,MongoDB 事务需要副本集(Replica set)。Atlas 集群默认就是副本集,因此可以直接使用。如果你在本地运行 MongoDB,则需要将其配置为副本集。
createOrderUseCase bean 首先创建普通的 CreateOrderService,然后将其包装在 TransactionalCreateOrderUseCase 中。控制器通过 CreateOrderUseCase 接口接收事务包装器,完全感知不到差异。
你可能会好奇为什么适配器存储库使用 @Component,而服务在配置类中使用 @Bean。适配器类已经位于外层,使用 Spring 注解是合理的。但服务类位于应用层,该层不应依赖于 Spring。在 CreateOrderService 上添加 @Service 会导致应用层引入 Spring 依赖,而应用层应该保持无框架化。BeanConfiguration 中的 @Bean 方法从外部进行整合,保持了应用层的纯净。
这也使得整合过程一目了然。你可以清楚地看到哪些实现支持哪些接口。ProductRepository 和 OrderRepository 参数由 Spring 根据 @Component 注解的适配器类(MongoProductRepository 和 MongoOrderRepository)进行解析。
为了了解这一切是如何连接的,可以跟踪 POST /orders 请求在各层之间的流转:
每一层只依赖于其内部的层。控制器知道用例接口但不知道服务,服务知道领域对象和存储库接口但不知道 MongoDB 文档。MongoDB 适配器实现了存储库接口并处理所有与数据库相关的细节。
在流程的最底层,Spring 已将 MongoProductRepository 和 MongoOrderRepository 注入到 ProductRepository 和 OrderRepository 接口之后。服务层永远看不到这些具体实现类。如果你切换到不同的适配器,请求将通过相同的路径,但在底层命中不同的数据库。
结论
整洁架构(Clean Architecture)使你的领域模型和业务逻辑独立于 MongoDB 和 Spring Boot。依赖规则通过包结构和接口来强制执行。MongoDB 是最外层的一个可插拔适配器,而不是会泄漏到业务逻辑中的关注点。
这种方法增加了一些前期结构工作:分离的文档类、映射器和端口接口。但随着项目的增长,这种投入是值得的。增加一个新用例只需编写一个服务类和一个 @Bean 方法。更换数据库只需编写一个新的适配器包。领域逻辑不会因为基础设施的原因而改变。
这种分离还使测试变得更加实用。你可以使用普通的 JUnit 测试来测试领域模型,无需任何 Spring 上下文。你可以通过传入模拟(Mock)或内存中的存储库实现来测试服务。需要 MongoDB 的集成测试则仅触及适配器层。
你可以在配套仓库中找到完整的项目,并将其作为你自己应用程序的起点。作为后续步骤,尝试在目录中添加更多用例(更新产品、取消订单),或者尝试将 MongoDB 适配器替换为内存实现,看看领域层是如何保持不变的。