《Spring Boot + JPA 实战》第5招 Repository边界
01、痛点
我是老李。前几天,团队里一个新人问我:为什么我们的 Repository 全都继承了 JpaRepository,明明很多方法业务代码根本不该用,但每次 Code Review 都没人提。我让他看一个真实场景:订单服务里,业务代码为了清理测试数据,直接调用 orderRepository.deleteAll(),结果在生产预发环境差点把整张订单表清空。幸亏有备份,不然就是事故。
这不是孤例。很多项目里,CustomerRepository、OrderRepository、ProductRepository,一律 extends JpaRepository<Entity, Long>,于是 save、saveAll、delete、deleteAll、deleteInBatch、flush 全部暴露给 Service。开发人员写起来确实爽,想要什么方法直接调,代码量少。但时间一长,数据访问边界就被冲垮了:领域聚合原本只允许“新增客户、按邮箱查客户、修改客户信息”,结果现在任何人拿到 CustomerRepository 都能直接物理删除客户,绕过业务上的注销流程。
更要命的是,这种“方便”是温水煮青蛙。今天手滑调用 deleteAll,明天可能就在循环里批量 flush,后天发现有人在事务里直接 deleteInBatch 绕过了一级缓存和审计。表面上看是少写几行代码,实际上是把数据库的万能遥控器塞给了每一个 Service,领域规则形同虚设。
为什么现在要讲这个?因为微服务和 DDD 落地之后,越来越多的团队开始重视聚合和仓储边界,但很多老项目还在无脑继承 JpaRepository。这个问题不解决,后续的架构治理、代码评审、甚至数据安全都是空谈。
02、是什么
要理解 Repository 的价值,先得分清一组接口:Repository、CrudRepository、PagingAndSortingRepository、JpaRepository。
Repository 是 Spring Data 里的标记接口,本身不定义任何方法,只用来标识“这是一个仓储”。它最重要的作用不是提供能力,而是告诉 Spring Data:请为这个接口生成代理,并根据方法名解析查询。
CrudRepository 在 Repository 基础上增加了一组通用的增删改查方法:save、saveAll、findById、existsById、findAll、findAllById、count、delete、deleteById、deleteAll、deleteAllById 等。注意,这里的 delete 是物理删除,直接操作数据库。
PagingAndSortingRepository 继承 CrudRepository,额外提供 findAll(Sort)、findAll(Pageable) 两个分页排序查询方法。它的价值在于标准化分页参数传递,但依然没有脱离“通用数据访问”的范畴。
JpaRepository 继承 PagingAndSortingRepository,又增加了一批 JPA 特有的方法:flush、saveAndFlush、saveAllAndFlush、deleteInBatch、deleteAllInBatch、getReferenceById 等。这些方法越来越贴近 JPA 底层实现,性能优化空间大,但危险系数也更高。比如 deleteInBatch 会一次性生成批量删除 SQL,绕过一级缓存和实体生命周期回调,审计、乐观锁、级联策略都可能失效。
很多开发者没有意识到:你继承 JpaRepository,等于把上述所有方法一次性全部暴露给调用方。哪怕业务上只允许“按主键查询客户”和“保存客户”,但只要接口类型是 JpaRepository,Service 层就可以随时调 deleteAll。这不是能力不足,而是能力过剩。
那 Spring Data 是怎么做到“你声明一个接口,它就能给你实现”的?核心是 Repository Proxy 和 SimpleJpaRepository。
SimpleJpaRepository 是 JpaRepository 接口的默认实现类。它内部持有一个 EntityManager,所有通用方法都是基于 JPA 的 EntityManager 实现的,比如 save 内部调用 persist 或 merge,delete 内部调用 remove,findAll 拼接 JPQL 查询。Spring Data JPA 启动时会扫描所有继承 Repository 的接口,为每个接口生成一个 JDK 动态代理(Repository Proxy)。代理在运行时会根据方法签名路由:如果是 SimpleJpaRepository 里已有的方法,就委托给 SimpleJpaRepository;如果是自定义方法,比如 findByEmail,就根据方法名解析出查询策略,生成对应的 JPA 查询。这就是为什么你只需要写接口,不用写实现类。
关键点来了:代理的生成依据是接口类型和方法签名。你继承 JpaRepository,代理就会把 SimpleJpaRepository 的全部方法接到你的接口上。你只继承 Repository 并声明少数方法,代理就只暴露这些方法。换句话说,接口继承层级直接决定了数据访问契约的宽度。
03、为什么
为什么我们总是不自觉地继承 JpaRepository?
第一,省事。继承 JpaRepository 之后,常用方法开箱即用,不用声明、不用实现。对于一个刚接触 Spring Data 的开发者来说,这几乎是最短路径。
第二,示例诱导。网上大量教程、博客、甚至官方快速入门,开篇就是 interface UserRepository extends JpaRepository<User, Long>。看得多了,大家默认这就是标准写法。
第三,缺乏边界意识。很多团队没有聚合、限界上下文的概念,Repository 被简单理解成“封装数据库操作的工具类”。既然是工具类,方法越多越好,谁也不会嫌功能多。
但问题恰恰出在这里。
Repository 的真正价值,不是让所有 Service 都获得数据库万能遥控器,而是表达一个聚合允许执行的数据访问契约。这句话需要展开。
在领域驱动设计里,聚合是一组强一致性的对象集合,每个聚合有一个根实体。外部对聚合内部对象的访问,必须通过聚合根进行。而 Repository 就是聚合根的持久化入口:它负责把聚合从数据库里取出来,或者把聚合存回去。Repository 的方法集合,应该只包含这个聚合在业务上允许的数据访问操作。
比如 Customer 聚合,业务上只允许创建客户、按邮箱查询客户、修改客户资料、注销客户(软删除)。那么 CustomerRepository 应该只有 save、findById、findByEmail、existsByEmail 这几个方法,绝对不应该出现 deleteAll、deleteInBatch 这种物理清除手段。因为物理删除会绕过注销流程,破坏审计和合规要求。
再比如 Order 聚合,订单创建后不能随意删除,只能取消或关闭。所以 OrderRepository 里不应该有 delete 方法。如果继承了 JpaRepository,Service 层就可能直接调 deleteById 把订单干掉,订单历史、支付记录、库存回滚等关联逻辑全部被绕过。
这就是“无脑继承 JpaRepository 会扩大数据访问边界”的原因:你不是在写一个订单仓储,而是在发一个数据库万能遥控器。每个人都有权限按下“删除全部”的按钮,只是暂时没人按错而已。
为什么现在必须讲?因为系统越复杂,这种边界模糊的代价越高。微服务拆分后,每个服务的数据访问面变窄,但如果没有在代码层面收窄,拆分只是物理隔离,逻辑上依然是一团乱麻。现在正是架构演进的关键窗口,收窄 Repository 边界,成本最低。
04、架构
从架构层面看,收窄 Repository 边界,本质上是把“领域层的数据访问契约”与“基础设施层的实现能力”解耦。
推荐的做法是:仓储接口定义在领域层,只声明聚合需要的方法;实现放在基础设施层,可以利用 Spring Data JPA 的代理机制自动生成。领域层不依赖任何 JPA 接口,只依赖一个最基础的 Repository 标记接口(或者自定义的轻量基础接口)。
具体来说,可以定义一个 BaseRepository<T, ID> extends Repository<T, ID>,然后每个聚合的仓储接口继承这个 BaseRepository,并按需声明方法。
为什么不建议直接继承 JpaRepository?因为 JpaRepository 属于 Spring Data JPA 的实现细节,它把 flush、batch delete 等基础设施能力带进了领域接口。领域层一旦依赖 JpaRepository,就等于依赖了 JPA 的底层 API,不仅扩大了数据访问边界,还增加了领域层与具体 ORM 框架的耦合。
更好的做法是:基础设施层仍然可以使用 SimpleJpaRepository 作为内部实现,但这些实现只存在于运行时,不暴露到接口上。比如你定义一个 CustomerRepository extends Repository<Customer, Long> { Optional
这样架构上的收益是:
- 领域层保持纯净,不依赖 JPA;
- 数据访问契约清晰,每个聚合能做什么一目了然;
- 危险操作被隔离,物理删除、批量更新等只能通过专门的应用服务或内部组件执行;
- 测试更容易,因为仓储接口只有几个方法,Mock 成本大幅降低。
有人可能会问:如果某些聚合确实需要批量更新或删除怎么办?答案不是把 deleteInBatch 暴露在仓储接口上,而是单独定义专用的数据操作组件(比如 CustomOrderRepository 的内部接口),或者使用专门的批量处理服务。不要让普通业务代码直接拿到批量删除能力。
05、实战
下面用三个聚合——Customer、Order、Product——演示如何建立窄接口 Repository。
先给一个共同的标记接口,避免每个仓储都直接继承 Spring Data 的 Repository:
import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.Repository;
@NoRepositoryBean
public interface BaseRepository<T, ID> extends Repository<T, ID> {
}
这里使用 @NoRepositoryBean 防止 Spring Data 为这个基础接口生成代理。
Customer 聚合:业务上只允许按 ID 查询、按邮箱查询、保存客户、判断邮箱是否已存在。不提供删除和批量操作。
public interface CustomerRepository extends BaseRepository<Customer, Long> {
Optional<Customer> findById(Long id);
Optional<Customer> findByEmail(Email email);
Customer save(Customer customer);
boolean existsByEmail(Email email);
}
有了这个接口,Service 层只能做四件事:按 ID 查、按邮箱查、保存、判断邮箱存在。物理删除客户的按钮直接消失,注销流程只能走专门的 cancel 方法。
Order 聚合:订单不允许物理删除,只能保存、按 ID 查、按状态查、统计某状态数量。不提供 delete 相关方法。
public interface OrderRepository extends BaseRepository<Order, Long> {
Optional<Order> findById(Long id);
Order save(Order order);
List<Order> findByStatus(OrderStatus status);
long countByStatus(OrderStatus status);
}
注意,这里没有 deleteById、deleteAll,也没有 saveAll、flush。如果有人在 Service 里写 orderRepository.deleteById(id),编译时就会报错,因为接口里根本没有这个方法。
Product 聚合:商品可以保存、按 ID 查、按分类分页查询,并需要更新库存。但库存更新必须走明确的领域方法,不能暴露通用 save 让外部随意改库存字段。
public interface ProductRepository extends BaseRepository<Product, Long> {
Optional<Product> findById(Long id);
Product save(Product product);
Page<Product> findByCategory(Category category, Pageable pageable);
@Modifying
@Query("update Product p set p.stock = p.stock - :quantity where p.id = :id and p.stock >= :quantity")
int decreaseStock(@Param("id") Long id, @Param("quantity") int quantity);
}
decreaseStock 方法用 JPQL 实现扣减库存,并且加了条件 p.stock >= :quantity 防止超卖。这个方法名是业务动作,不是泛化的 update,调用方一看就知道它在做什么。返回 int 表示影响行数,调用方可以判断是否成功。
注意,decreaseStock 使用了 @Modifying,这类方法需要事务支持。但接口本身只暴露了一个明确的扣库存动作,没有暴露任意批量更新能力。
对比一下:如果这三个接口都直接继承 JpaRepository,那么 CustomerRepository 会多出 deleteAll、deleteAllInBatch;OrderRepository 会多出 deleteById、deleteInBatch;ProductRepository 会多出 flush、saveAllAndFlush。任何一个 Service 都能调用这些危险方法。现在经过收窄,业务代码只能做接口里声明的事情,边界一目了然。
有人担心:这样是不是要写很多方法声明?其实不会。一个聚合真正需要的数据访问操作通常不超过 5~8 个,声明成本极低。而且 Spring Data 会根据方法名自动生成查询,不需要实现类。牺牲一点“万能感”,换来的是领域边界的清晰和安全。
06、洞见
DAO 样板代码可以减少,但领域边界不能因此消失。
Spring Data JPA 帮我们省掉了大量 DAO 实现样板,这是好事。但省样板不等于省设计。很多团队把“少写代码”误当成“少做设计”,用 JpaRepository 的万能接口替代了每个聚合应该独立声明的数据访问契约。
更深层的问题是:Repository 不是数据库操作工具,而是领域模型的一部分。它代表聚合根的持久化边界。当你定义一个 OrderRepository 时,你其实是在告诉团队:订单聚合只允许这些数据操作,其他一律不允许。如果接口上挂着 deleteAll,你就是在告诉所有人:订单可以随意物理删除。这与你口头强调“订单不能删除”是矛盾的。
领域边界为什么不能因为 DAO 样板减少而消失?因为代码是执行时的真实约束。口头约定、文档、代码评审都会失效,只有接口签名不会说谎。你在接口上不声明 delete,编译器就是最忠诚的守门人。你在接口上声明了 deleteAll,任何未来的维护者都会认为这是被允许的,并且很可能在某些“特殊场景”里使用它。
收窄 Repository 的另一个好处是:它迫使你思考每个聚合真正需要什么。这个过程本身就是在梳理领域模型。Customer 需要 existsByEmail 吗?如果不需要,就不该有。Order 需要 findAll 吗?如果业务上从不列出所有订单,就不该有。Product 需要批量更新吗?如果只有一个明确的扣库存动作,就不要暴露 saveAll。每一个方法的增加,都应该是业务驱动的,而不是因为 JpaRepository 自带。
这就是“DAO 样板代码可以减少,但领域边界不能因此消失”的真正含义:工具可以帮我们少写重复代码,但不能替我们做设计决策。Repository 的接口设计,就是聚合边界的设计。
07、行动
现在就可以动手检查你的项目。步骤如下:
第一,列出所有 Repository 接口,逐个确认它们继承了什么。凡是直接继承 JpaRepository 的,标记出来。
第二,对每个聚合,列出业务上真正需要的数据访问操作。注意是“业务需要”,不是“可能用得上”。比如 Customer 聚合,业务上需要 findById、findByEmail、save、existsByEmail;如果暂时没有注销物理删除需求,就不需要 delete。
第三,将接口改为继承 BaseRepository(或直接继承 Repository),只保留业务需要的方法。把原来的 JpaRepository 继承去掉。这一步会直接导致所有调用危险方法的代码编译失败,这正是一份现成的整改清单。
第四,优先处理核心聚合,比如订单、支付、账户。这些聚合的误操作风险最高,先收窄它们。
第五,对确实需要批量更新或物理删除的场景,不要重新把 deleteInBatch 加回仓储接口,而是定义专门的应用服务或内部组件,并限制调用范围。例如,只有订单归档任务可以调用批量删除,普通业务服务不能。
第六,把“Repository 接口不得直接继承 JpaRepository”写入团队编码规范,并在 Code Review 中重点检查。可以配合静态检查工具,禁止出现 extends JpaRepository 的声明。
第七,逐步迁移,不要一次性全部改完。可以先从新聚合、新服务开始,老代码慢慢过渡。每次迁移后,运行测试确保行为一致。
最后,定期回顾。每季度检查一次 Repository 接口方法,看有没有新增的越界方法。边界是动态的,需要持续维护。
08、小结
Repository 的价值不是让你少写 DAO,而是让你明确每个聚合的数据访问契约。Repository、CrudRepository、PagingAndSortingRepository、JpaRepository 的能力层层叠加,越往下越危险。无脑继承 JpaRepository,等于把数据库万能遥控器发给所有 Service,数据访问边界被无限扩大。
通过为 Customer、Order、Product 分别建立窄接口 Repository,可以看到:收窄之后,代码不仅没有变复杂,反而更清晰、更安全。delete、batch update 等危险操作被隔离在业务接口之外,领域规则通过编译期约束得到保证。
记住一句话:DAO 样板代码可以减少,但领域边界不能因此消失。工具越强大,我们越需要清醒地定义边界。
09、链接与概念说明
概念说明:
- Repository:Spring Data 的标记接口,用于标识仓储,本身不定义数据操作方法。
- CrudRepository:提供标准 CRUD 方法,包括 save、delete、deleteAll 等。
- PagingAndSortingRepository:继承 CrudRepository,增加分页和排序方法。
- JpaRepository:继承 PagingAndSortingRepository,增加 flush、batch delete 等 JPA 特有方法。
- SimpleJpaRepository:JpaRepository 接口的默认实现,基于 EntityManager 完成通用操作。
- Repository Proxy:Spring Data 为仓储接口生成的 JDK 动态代理,根据接口声明路由方法调用。
- 聚合与仓储:领域驱动设计中,仓储是聚合根的持久化入口,方法集合应表达聚合允许的数据访问契约。
推荐进一步阅读:Spring Data JPA 官方文档“Defining Repository Interfaces”部分,以及 Eric Evans《领域驱动设计》中关于 Repository 的章节。

浙公网安备 33010602011771号