Ohhnews

分类导航

$ cd ..
foojay原文

基于Spring Boot与MongoDB的整洁架构实践

#整洁架构#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 MongoDBSpring 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. 项目结构

该项目的包布局如下:

dev.farhan.catalog/
  domain/
    model/          → Product, Order, OrderItem
    port/
      in/           → CreateOrderUseCase, GetProductCatalogUseCase
      out/          → ProductRepository, OrderRepository
  application/
    service/        → CreateOrderService, GetProductCatalogService
  adapter/
    in/
      web/          → OrderController, ProductController, request/response DTOs
    out/
      persistence/  → ProductDocument, OrderDocument, MongoProductRepository,
                      MongoOrderRepository, TransactionalCreateOrderUseCase, mappers
  CatalogApplication.java
  BeanConfiguration.java

domain 包不导入 adapterapplicationapplication 包导入 domain 但不导入 adapteradapter 包同时导入两者,但依赖箭头始终指向内部。如果开发人员不小心在领域层导入了 MongoDB 类,这种包结构会立刻暴露出问题。

既然我们已经了解了端口的含义,目录名称就很容易理解了。in 端口定义外部世界可以要求应用做什么(用例);out 端口定义应用需要外部世界提供什么(例如存储库、外部服务)。

你可以克隆 配套仓库 来获取预构建的结构并进行学习。

3. 构建领域层

领域模型是没有任何框架依赖的普通 Java 类。首先是 Product

$ java
public class Product {

    private String id;
    private String name;
    private String description;
    private BigDecimal price;
    private int stockQuantity;

    public Product(String id, String name, String description, BigDecimal price, int stockQuantity) {
        if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("价格必须为正数");
        }
        if (stockQuantity < 0) {
            throw new IllegalArgumentException("库存数量不能为负数");
        }
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }

    public void decreaseStock(int quantity) {
        if (quantity > this.stockQuantity) {
            throw new IllegalArgumentException(
                    "无法减少 " + quantity + " 库存: 当前仅剩 " + this.stockQuantity
            );
        }
        this.stockQuantity -= quantity;
    }

    // 为简洁起见,省略 getter
}

构造函数验证价格是否为正数且库存不为负。decreaseStock 方法强制执行业务规则:不能订购超过现有库存的数量。此规则属于实体,而不属于服务类。如果库存验证逻辑放在服务中,你可能会通过代码库中其他地方直接调用实体来绕过它。

OrderItem 表示订单中的一行:

$ java
public class OrderItem {

    private String productId;
    private String productName;
    private BigDecimal price;
    private int quantity;

    public OrderItem(String productId, String productName, BigDecimal price, int quantity) {
        if (quantity < 1) {
            throw new IllegalArgumentException("数量至少为 1");
        }
        this.productId = productId;
        this.productName = productName;
        this.price = price;
        this.quantity = quantity;
    }

    // 为简洁起见,省略 getter
}

Order 类使用私有构造函数和公共 create 方法。这种模式(称为静态工厂方法)强制所有调用者通过 create 进行创建,以便我们能够强制执行始终保持成立的规则。在这种情况下,每个订单必须至少包含一个项目,且总价是根据这些项目计算出来的,而不是由调用者设置的。

$ java
public class Order {

    private String id;
    private List<OrderItem> items;
    private BigDecimal totalAmount;
    private Instant createdAt;

    private Order(String id, List<OrderItem> items, BigDecimal totalAmount, Instant createdAt) {
        this.id = id;
        this.items = items;
        this.totalAmount = totalAmount;
        this.createdAt = createdAt;
    }

    public static Order create(String id, List<OrderItem> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("订单必须至少包含一个项目");
        }

        BigDecimal total = items.stream()
                .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        return new Order(id, List.copyOf(items), total, Instant.now());
    }

    // 为简洁起见,省略 getter
}

List.copyOf(items) 创建了列表的不可变副本。如果没有它,传递原始列表的人可能会在订单创建后添加或删除项目,从而破坏总价计算。这种防御性拷贝是领域对象中的常见做法。

存储库端口接口定义了领域需要外部世界提供什么:

$ java
public interface ProductRepository {

    Optional<Product> findById(String id);
    List<Product> findAll();
    Product save(Product product);
}
$ java
public interface OrderRepository {

    Order save(Order order);
    Optional<Order> findById(String id);
}

这些是位于 domain.port.out 包中的普通 Java 接口。没有 Spring 注解,没有 MongoRepository 扩展。如果你从 pom.xml 中删除 Spring 和 MongoDB 依赖,这个包仍然可以编译。这就是重点所在:领域层只依赖于 JDK,不依赖其他任何东西。

这对测试至关重要。你可以为 Product.decreaseStock()Order.create() 编写单元测试,而无需启动 Spring 上下文或连接数据库。你还可以创建一个简单的内存版 ProductRepository 实现(将产品存储在 HashMap 中),并在不接触 MongoDB 的情况下测试服务。由于业务规则不依赖任何框架,因此它们可以在不同的基础设施之间移植。

4. 构建应用层

用例接口定义了外部世界可以要求应用做什么。这些是入站端口(Inbound Ports):

$ java
public interface CreateOrderUseCase {

    Order execute(CreateOrderCommand command);

    record CreateOrderCommand(List<OrderItemRequest> items) {
        public record OrderItemRequest(String productId, int quantity) {}
    }
}
$ java
public interface GetProductCatalogUseCase {

    List<Product> execute();
}

这里有几点需要说明。CreateOrderCommandOrderItemRequest 是 Java Record。Record 是一种定义纯数据类的简洁方式。编写 record CreateOrderCommand(List<OrderItemRequest> items) 会自动为你提供构造函数、getter (items())、equalshashCodetoString。你会在整个项目中看到 Record 被用于 DTO 和命令对象,因为它们是不带行为的数据载体。

为什么要将输入封装在命令对象中,而不是直接将 List<OrderItemRequest> 传递给 execute?因为如果用例以后需要更多上下文(例如客户 ID 或折扣码),你只需在 Record 中添加字段,而无需更改方法签名及所有调用者。命令对象为你提供了一个稳定的接口。

GetProductCatalogUseCase 更简单:它不接收输入并返回完整的产品列表。

服务实现通过端口接口协调领域对象:

$ java
public class CreateOrderService implements CreateOrderUseCase {

    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;

    public CreateOrderService(ProductRepository productRepository, OrderRepository orderRepository) {
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
    }

    @Override
    public Order execute(CreateOrderCommand command) {
        List<OrderItem> orderItems = new ArrayList<>();

        for (CreateOrderCommand.OrderItemRequest itemRequest : command.items()) {
            Product product = productRepository.findById(itemRequest.productId())
                    .orElseThrow(() -> new IllegalArgumentException(
                            "找不到产品: " + itemRequest.productId()
                    ));

            product.decreaseStock(itemRequest.quantity());

            orderItems.add(new OrderItem(
                    product.getId(),
                    product.getName(),
                    product.getPrice(),
                    itemRequest.quantity()
            ));

            productRepository.save(product);
        }

        Order order = Order.create(UUID.randomUUID().toString(), orderItems);
        return orderRepository.save(order);
    }
}

对于命令中的每个项目,服务会查找产品、调用 product.decreaseStock() 以执行库存规则、构建 OrderItem 并保存更新后的产品。然后它从收集到的项目中创建 Order 并通过存储库端口进行持久化。UUID.randomUUID().toString() 为订单生成一个随机的唯一 ID。我们在应用层生成 ID 而不是让 MongoDB 分配,是因为领域不应该依赖数据库行为。

这里没有 @Service@Autowired。这些是普通的 Java 类,通过构造函数接受接口。Spring 稍后会进行组装,但应用层不知道也不关心这一点。服务协调领域对象并调用存储库接口。它不包含业务规则(库存验证在 Product 实体中),也不关心数据是如何存储的(这些逻辑隐藏在端口接口之后)。

不过,这个服务有一个隐患:每个 productRepository.save() 调用都会立即提交。如果最后的 orderRepository.save() 失败,你将得到库存已减少但没有相应订单的情况。解决方法是使用数据库事务,在失败时回滚所有更改。但如果直接在 CreateOrderService 上添加 @Transactional,会将 Spring 导入引入应用层。相反,我们在适配器层使用一个精简的包装器来处理这个问题(见第 6 节)。

GetProductCatalogService 只做一件事:

$ java
public class GetProductCatalogService implements GetProductCatalogUseCase {

    private final ProductRepository productRepository;

    public GetProductCatalogService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Override
    public List<Product> execute() {
        return productRepository.findAll();
    }
}
```## 5. 构建 MongoDB 适配器

到目前为止,项目中没有任何地方引用 MongoDBSpring。适配器层是发生变化的地方。文档类(Document classes)与领域模型(Domain models)是分离的。`ProductDocument` 如下所示:

```java
@Document(collection = "products")
public class ProductDocument {

    @Id
    private String id;

    @Field
    private String name;

    @Field
    private String description;

    @Field
    private BigDecimal price;

    @Field("stock_quantity")
    private int stockQuantity;

    // 为简洁起见,省略了构造函数、getter 和 setter
}

OrderDocument 使用 @Document(collection = "orders") 进行注解,并包含一个 OrderItemDocument 对象列表。OrderItemDocument 是一个普通的类,由于它嵌入在订单文档中,因此没有使用 @Document 注解。

为什么要将文档类与领域模型分开?领域模型 Product 包含业务方法和验证逻辑,而 ProductDocument 仅用于 MongoDB 序列化存储数据。将两者混合会使领域模型与数据库模式(Schema)耦合。如果 MongoDB 模式发生变化(例如重命名某个字段或重构嵌套文档),领域模型依然保持不变,只需由映射器(Mapper)来处理这些差异。

映射器类通过静态方法处理转换:

$ java
public class ProductMapper {

    public static ProductDocument toDocument(Product product) {
        return new ProductDocument(
                product.getId(),
                product.getName(),
                product.getDescription(),
                product.getPrice(),
                product.getStockQuantity()
        );
    }

    public static Product toDomain(ProductDocument document) {
        return new Product(
                document.getId(),
                document.getName(),
                document.getDescription(),
                document.getPrice(),
                document.getStockQuantity()
        );
    }
}

这是简单的逐字段转换。对于这种规模的项目,不需要使用任何映射库。OrderMapper 遵循相同的模式,处理嵌套的 OrderItemOrderItemDocument 的转换。

存储库实现(Repository implementations)将领域端口接口与 Spring Data MongoDB 连接起来:

$ java
@Component
public class MongoProductRepository implements ProductRepository {

    private final SpringDataMongoProductRepository springDataRepository;

    public MongoProductRepository(SpringDataMongoProductRepository springDataRepository) {
        this.springDataRepository = springDataRepository;
    }

    @Override
    public Optional<Product> findById(String id) {
        return springDataRepository.findById(id)
                .map(ProductMapper::toDomain);
    }

    @Override
    public List<Product> findAll() {
        return springDataRepository.findAll().stream()
                .map(ProductMapper::toDomain)
                .toList();
    }

    @Override
    public Product save(Product product) {
        ProductDocument document = ProductMapper.toDocument(product);
        ProductDocument saved = springDataRepository.save(document);
        return ProductMapper.toDomain(saved);
    }
}

MongoProductRepository 实现了领域的 ProductRepository 接口,并添加了 @Component 注解,以便 Spring 将其纳入依赖注入。在内部,它使用了 SpringDataMongoProductRepository,这是一个标准的 Spring Data 接口:

$ java
public interface SpringDataMongoProductRepository extends MongoRepository<ProductDocument, String> {
}

该接口扩展了 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 存储库实现和新的映射器。domainapplication 包将完全不需要改动。业务规则和用例逻辑保持原样。

6. 使用 Spring Boot 进行整合(Wiring)

REST 控制器是入站适配器。ProductController 暴露了 GET /products 接口:

$ java
@RestController
@RequestMapping("/products")
public class ProductController {

    private final GetProductCatalogUseCase getProductCatalogUseCase;

    public ProductController(GetProductCatalogUseCase getProductCatalogUseCase) {
        this.getProductCatalogUseCase = getProductCatalogUseCase;
    }

    @GetMapping
    public List<ProductResponse> getProducts() {
        return getProductCatalogUseCase.execute().stream()
                .map(this::toResponse)
                .toList();
    }

    private ProductResponse toResponse(Product product) {
        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getDescription(),
                product.getPrice(),
                product.getStockQuantity()
        );
    }
}

OrderController 暴露了 POST /orders 接口,并将传入的 JSON 映射为 CreateOrderCommand

$ java
@RestController
@RequestMapping("/orders")
public class OrderController {

    private final CreateOrderUseCase createOrderUseCase;

    public OrderController(CreateOrderUseCase createOrderUseCase) {
        this.createOrderUseCase = createOrderUseCase;
    }

    @PostMapping
    public ResponseEntity<CreateOrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
        CreateOrderCommand command = new CreateOrderCommand(
                request.items().stream()
                        .map(item -> new CreateOrderCommand.OrderItemRequest(
                                item.productId(),
                                item.quantity()
                        ))
                        .toList()
        );

        Order order = createOrderUseCase.execute(command);

        CreateOrderResponse response = new CreateOrderResponse(
                order.getId(),
                order.getTotalAmount(),
                order.getCreatedAt().toString()
        );

        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
        return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
    }
}

底部的 @ExceptionHandler 会捕获领域验证(如 Product.decreaseStock())抛出的 IllegalArgumentException,并返回带有错误信息的 400 响应。如果没有这个处理,Spring 会返回一个带有堆栈跟踪的通用 500 错误。

请求和响应 DTO 定义为 adapter.in.web 包中的 Java record。CreateOrderRequestOrderItemRequestCreateOrderResponseProductResponse 都是不含逻辑的 record,仅作为 JSON 序列化的数据载体。控制器在这些 DTO 和领域对象之间进行映射。控制器从不直接接触 MongoDB 类。

还记得第 4 节提到的事务问题吗?CreateOrderService 逐个保存产品,如果最后的订单保存失败,库存已经减少但订单却未创建。我们需要使用 @Transactional,但将其直接添加到服务层会把 Spring 引入应用层。解决方案是在适配器层添加一个轻量级包装器:

$ java
public class TransactionalCreateOrderUseCase implements CreateOrderUseCase {

    private final CreateOrderUseCase delegate;

    public TransactionalCreateOrderUseCase(CreateOrderUseCase delegate) {
        this.delegate = delegate;
    }

    @Transactional
    @Override
    public Order execute(CreateOrderCommand command) {
        return delegate.execute(command);
    }
}

TransactionalCreateOrderUseCase 与其他基础设施代码一起存在于 adapter.out.persistence 中。它实现了相同的 CreateOrderUseCase 接口,委托给 CreateOrderService,并添加了 @Transactional,以确保 execute 中的所有数据库写入要么全部成功,要么全部回滚。CreateOrderService 本身保持不受 Spring 注解的影响。

整合工作在一个带有显式 @Bean 方法的 @Configuration 类中完成:

$ java
@Configuration
public class BeanConfiguration {

    @Bean
    public MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) {
        return new MongoTransactionManager(dbFactory);
    }

    @Bean
    public CreateOrderUseCase createOrderUseCase(ProductRepository productRepository,
                                                  OrderRepository orderRepository) {
        CreateOrderService service = new CreateOrderService(productRepository, orderRepository);
        return new TransactionalCreateOrderUseCase(service);
    }

    @Bean
    public GetProductCatalogUseCase getProductCatalogUseCase(ProductRepository productRepository) {
        return new GetProductCatalogService(productRepository);
    }
}

MongoTransactionManager bean 告诉 Spring 如何管理 MongoDB 的事务。如果没有它,@Transactional 将不起作用。请注意,MongoDB 事务需要副本集(Replica set)。Atlas 集群默认就是副本集,因此可以直接使用。如果你在本地运行 MongoDB,则需要将其配置为副本集。

createOrderUseCase bean 首先创建普通的 CreateOrderService,然后将其包装在 TransactionalCreateOrderUseCase 中。控制器通过 CreateOrderUseCase 接口接收事务包装器,完全感知不到差异。

你可能会好奇为什么适配器存储库使用 @Component,而服务在配置类中使用 @Bean。适配器类已经位于外层,使用 Spring 注解是合理的。但服务类位于应用层,该层不应依赖于 Spring。在 CreateOrderService 上添加 @Service 会导致应用层引入 Spring 依赖,而应用层应该保持无框架化。BeanConfiguration 中的 @Bean 方法从外部进行整合,保持了应用层的纯净。

这也使得整合过程一目了然。你可以清楚地看到哪些实现支持哪些接口。ProductRepositoryOrderRepository 参数由 Spring 根据 @Component 注解的适配器类(MongoProductRepositoryMongoOrderRepository)进行解析。

为了了解这一切是如何连接的,可以跟踪 POST /orders 请求在各层之间的流转:

每一层只依赖于其内部的层。控制器知道用例接口但不知道服务,服务知道领域对象和存储库接口但不知道 MongoDB 文档。MongoDB 适配器实现了存储库接口并处理所有与数据库相关的细节。

在流程的最底层,Spring 已将 MongoProductRepositoryMongoOrderRepository 注入到 ProductRepositoryOrderRepository 接口之后。服务层永远看不到这些具体实现类。如果你切换到不同的适配器,请求将通过相同的路径,但在底层命中不同的数据库。

结论

整洁架构(Clean Architecture)使你的领域模型和业务逻辑独立于 MongoDB 和 Spring Boot。依赖规则通过包结构和接口来强制执行。MongoDB 是最外层的一个可插拔适配器,而不是会泄漏到业务逻辑中的关注点。

这种方法增加了一些前期结构工作:分离的文档类、映射器和端口接口。但随着项目的增长,这种投入是值得的。增加一个新用例只需编写一个服务类和一个 @Bean 方法。更换数据库只需编写一个新的适配器包。领域逻辑不会因为基础设施的原因而改变。

这种分离还使测试变得更加实用。你可以使用普通的 JUnit 测试来测试领域模型,无需任何 Spring 上下文。你可以通过传入模拟(Mock)或内存中的存储库实现来测试服务。需要 MongoDB 的集成测试则仅触及适配器层。

你可以在配套仓库中找到完整的项目,并将其作为你自己应用程序的起点。作为后续步骤,尝试在目录中添加更多用例(更新产品、取消订单),或者尝试将 MongoDB 适配器替换为内存实现,看看领域层是如何保持不变的。