Spring Boot 4.0 整合 Spring Data JPA:实体设计与关联关系
前言
上一章的 MyBatis 把数据库行映射成页面 DTO。本章换一个方向:用 JPA 实体表达订单聚合,再在事务边界内转换为 DTO。
两种技术不是“二选一”。订单写入需要实体关系、级联和事务,复杂报表需要可控 SQL,两者可以在同一个项目里分工。
配套源码:dvsusan/spring-boot4-demo。
最终结果
创建一笔包含两个订单项的订单:
curl -X POST "http://localhost:8080/api/v1/db/orders" \
-H "Content-Type: application/json" \
-d '{
"customerNo":"C2001",
"customerName":"小红",
"items":[
{"productId":"P1001","productName":"机械键盘","quantity":1,"unitPrice":199.00},
{"productId":"P1002","productName":"鼠标垫","quantity":2,"unitPrice":29.00}
],
"metadata":{"channel":"app"}
}'返回订单金额 257.00 和两条明细。订单金额由服务端根据单价乘数量重新计算。

图 1:实体关系负责持久化一致性,DTO 负责对外契约,二者不能混成同一个对象。
第 1 步:先确定聚合边界
本章模型:
CustomerEntity:客户资料。PurchaseOrderEntity:订单聚合根。OrderItemEntity:订单项,生命周期属于订单。
订单项不能脱离订单独立存在,因此从订单删除订单项,应同步删除数据库行。客户与订单的关系不同,客户删除通常还要受订单业务规则约束,不能直接级联删除全部历史订单。
不要看到关联就加 CascadeType.ALL。级联操作代表生命周期,不只是“保存方便”。
第 2 步:定义客户实体
@Entity
@Table(name = "customers")
public class CustomerEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "tenant_id", nullable = false, length = 32)
private String tenantId;
@Column(name = "customer_no", nullable = false, length = 32)
private String customerNo;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
// 反向关系默认懒加载,不能在 Controller 中随意遍历。
@OneToMany(mappedBy = "customer")
private List<PurchaseOrderEntity> orders = new ArrayList<>();
protected CustomerEntity() {
}
}JPA 规范要求无参构造器。声明为 protected,既满足框架要求,也避免业务代码随意创建空对象。
第 3 步:定义订单聚合根
@Entity
@Table(name = "purchase_orders")
public class PurchaseOrderEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_no", nullable = false, length = 32)
private String orderNo;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private CustomerEntity customer;
@Column(name = "amount", nullable = false, precision = 12, scale = 2)
private BigDecimal amount;
@OneToMany(
mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
private List<OrderItemEntity> items = new ArrayList<>();
public void addItem(OrderItemEntity item) {
// 同时维护双向关系,集合和子实体的外键才不会分叉。
item.attachTo(this);
items.add(item);
}
}mappedBy = "order" 表示订单项持有外键。orphanRemoval 表示订单项从集合移除后,对应数据库行也删除。
第 4 步:定义订单项
/**
* 订单项属于订单聚合,外键由自身持有。
*/
@Entity
@Table(name = "order_items")
public class OrderItemEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false)
private PurchaseOrderEntity order;
@Column(name = "product_id", nullable = false, length = 32)
private String productId;
@Column(name = "product_name", nullable = false, length = 120)
private String productName;
@Column(name = "quantity", nullable = false)
private int quantity;
@Column(name = "unit_price", nullable = false, precision = 12, scale = 2)
private BigDecimal unitPrice;
void attachTo(PurchaseOrderEntity order) {
this.order = order;
}
}商品名和单价是订单快照。即使商品中心以后改名或调价,历史订单也必须保持原值。
第 5 步:声明 Repository
/**
* 详情查询一次抓取客户和订单项。
*/
public interface PurchaseOrderJpaRepository
extends JpaRepository<PurchaseOrderEntity, Long> {
@EntityGraph(attributePaths = {"customer", "items"})
Optional<PurchaseOrderEntity> findByTenantIdAndOrderNo(
String tenantId,
String orderNo
);
}接口方法名自动生成查询条件。@EntityGraph 在一次查询中抓取客户和订单项,避免 DTO 转换时触发多次懒加载。
不要把 @EntityGraph 无脑加到所有查询。抓取多个一对多集合可能产生笛卡尔积;复杂报表更适合 MyBatis。
第 6 步:关闭 Open Session in View
spring:
jpa:
# 事务结束后不再允许 Controller 触发懒加载。
open-in-view: false开启 Open Session in View 会让数据库 Session 一直活到页面渲染完成。它看似方便,实际上把懒加载藏到 Controller 或模板层,容易产生不可控查询。
关闭后,Service 必须在事务内把 Entity 转成 DTO:
// DTO 转换必须位于事务仍有效的 Service 内。
List<PersistentOrderView.Item> items = order.getItems()
.stream()
.map(item -> new PersistentOrderView.Item(
item.getProductId(),
item.getProductName(),
item.getQuantity(),
item.getUnitPrice()
))
.toList();这样离开事务后,Controller 只接触普通 Java 数据。
第 7 步:保存订单聚合
// 聚合根创建后通过 addItem 维护双向关系。
PurchaseOrderEntity order = new PurchaseOrderEntity(
orderNo,
command.tenantId(),
customer,
total,
command.metadata()
);
for (PlaceOrderCommand.PlaceOrderItem item : command.items()) {
order.addItem(new OrderItemEntity(
item.productId(),
item.productName(),
item.quantity(),
item.unitPrice()
));
}
PurchaseOrderEntity saved = orderRepository.saveAndFlush(order);订单根配置了 CascadeType.ALL,保存订单时订单项一起写入。flush 让主键和 SQL 立即发送,便于后续在同一事务内检查数据库状态。
服务端金额计算:
// 总金额始终由服务端可信单价计算。
BigDecimal total = command.items()
.stream()
.map(item -> item.unitPrice().multiply(
BigDecimal.valueOf(item.quantity())
))
.reduce(BigDecimal.ZERO, BigDecimal::add);不能接受客户端直接传来一个“总金额”并原样保存。客户端金额只能用于展示或对账,最终应付金额由服务端可信数据计算。
第 8 步:查询并验证关系
启动 PostgreSQL 和 db Profile 后:
curl -X PUT \
"http://localhost:8080/api/v1/db/inventory/P1001?quantity=10"
curl -X PUT \
"http://localhost:8080/api/v1/db/inventory/P1002?quantity=10"先创建订单,再使用返回的 orderNo 查询:
curl "http://localhost:8080/api/v1/db/orders/PO_REPLACE_ME" \
-H "X-Tenant-Id: demo"响应中应包含两条订单项,总金额为 257.00。自动化测试:
mvn -q testDatabaseCoreIntegrationTest 会验证 JPA 写入后的 MyBatis 分页结果和订单项统计。
常见问题排查
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
LazyInitializationException | DTO 转换发生在事务外 | 在 Service 事务内完成映射 |
| 保存订单但明细为空 | 只设置了外键,没有维护集合 | addItem 同时维护双向关系 |
| 删除订单项后数据库仍有记录 | 没配置 orphanRemoval | 按聚合生命周期配置 |
| 查询出现 N+1 | 每个订单单独加载客户或明细 | 使用 EntityGraph 或专用查询 DTO |
| 多集合抓取后结果重复 | 一对多 JOIN 产生笛卡尔积 | 拆分查询或改用 MyBatis |
| 字段类型与数据库不一致 | BigDecimal 映射成浮点列 | 使用 NUMERIC(12,2) |
本章小结
JPA 的重点不是少写 SQL,而是明确实体边界:
- 订单是聚合根。
- 订单项生命周期跟随订单。
- 关系默认懒加载。
- 事务内完成 DTO 转换。
- 复杂报表交给可控 SQL。
下一章会把订单写入和库存扣减放进同一个事务,并验证失败时哪些数据必须回滚。
练习题
增加订单取消:
- 订单状态改为
CANCELLED。 - 恢复每个订单项对应库存。
- 已支付订单不允许取消。
- 用一个事务保证状态和库存同时成功或同时失败。
上一节:MyBatis 多表查询与分页
下一节:事务传播、隔离与失效场景
相关阅读:
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
