Service、Repository 与 Mapper 职责:业务、持久化和编排怎样分离
创建订单需要保存订单,并登记同一用例的审计记录。第一条 INSERT 成功、第二条失败时,数据库究竟保留了什么,取决于两次写入是否加入同一事务。类都叫 Service 或 Repository,并不会改变这个结果。
安排代码职责时,可以从一次完整用例开始:谁决定需要做哪些操作,谁维护业务规则,谁控制提交,谁负责把数据写成数据库能够处理的形式。
一次用例需要哪些职责
入口、编排与业务规则
HTTP 入口解析请求、取得已认证身份、执行输入校验,再调用应用用例。它负责把结果或异常转为 HTTP 响应。任务与消息入口可以调用同一用例,但各自处理任务状态或消息确认。
应用服务决定操作顺序,例如载入订单、确认允许取消、释放预留、保存取消状态。需要一起提交的数据操作通常由这一层确定事务范围。领域对象或领域服务则表达取消条件、金额计算等业务规则,避免同一规则散落到每个入口。
简单 CRUD 可以由一个短小服务完成,不要求每个操作都先引入复杂领域模型。规则增长后,依据修改原因拆分:优惠计算与数据库查询由不同代码承担,会比把一个长方法机械切成多个 private 方法更容易测试。业务约定怎样决定聚合和允许的动作,可以结合 DDD 改价建模与实现 查看。
Repository、DAO 与两种 Mapper
| 名称 | 典型责任 | 应避免带出的内容 |
|---|---|---|
| Application Service | 编排一个用例、控制事务及外部协作 | HTTP Response、任意 SQL 字符串进入业务接口 |
| Domain Service | 不适合放进单个实体的业务计算或判断 | 框架请求、线程局部登录态等隐式输入 |
| Repository | 按业务需要保存、读取对象或聚合 | 将底层查询器直接交给所有调用方 |
| DAO / SQL Mapper | 执行 SQL、绑定参数、映射行 | 付款确认、优惠策略等上层业务决策 |
| Object Mapper | DTO、领域对象、视图之间的转换 | 偷偷查询数据库或发送远程请求 |
MyBatis Mapper 通常指 SQL 映射接口,MapStruct Mapper 指编译生成的对象转换,两者名字相近但工作不同。RowMapper 则负责把 JDBC 结果的一行转成对象。使用名称时应同时说明工具和职责。
Spring Data Repository 提供通用仓库抽象和相应扩展接口;领域设计中的 Repository 更强调业务对象的获取与保存。采用 Spring Data 接口并不自动完成聚合设计,需要继续决定公开哪些方法。Spring Data 核心概念
聚合存储与查询投影
写入订单时,订单行、总额和状态可能需要作为一个业务整体维护。Repository 可以围绕聚合根表达加载和保存,防止外部代码随意修改内部行后遗漏总额更新。
报表查询只需要订单号、买家名称和金额时,可以直接使用只读投影,不必载入整个对象图再逐个转换。写入模型强调合法状态,查询模型强调所需数据、排序和读取成本,二者可以采用不同实现。
这种分离可以发生在同一数据库、同一进程内。只有引入异步读模型时,才额外处理同步延迟、重建和来源版本,不能把所有查询投影都叫作分布式 CQRS。
数据操作怎样加入同一事务
在用例外侧建立提交范围
实验的应用服务只依赖三个端口:订单存储、审计写入和事务执行。核心代码如下:
public void execute(Order order) {
transactions.run(() -> {
orders.save(order);
audit.record(order.id());
});
}数据库适配器使用 Spring TransactionTemplate:
public void run(Runnable work) {
template.executeWithoutResult(status -> work.run());
}两次写入通过同一个 DataSource 的 JdbcTemplate 执行,由对应 JdbcTransactionManager 管理。运行时异常离开回调时,事务执行器进行回滚。应用层因此可以保持纯 Java,同时由外部装配提供真实事务机制。Spring 编程式事务
常见 Spring 项目也可以在应用服务上使用 @Transactional。代理方式下,要确认调用经过代理、事务管理器正确,以及回滚规则适用于实际异常类型。类内部直接调用自身方法,不会自动经过外部代理;新开线程也不会自动继承当前命令式事务。Spring 事务注解
存储方法需要表达具体语义
save 可以表示新增、覆盖、upsert 或 ORM 合并,调用方不能只凭名字推断行为。实验 JdbcOrders.save 明确执行 INSERT,重复主键会失败;不存在“自动更新已有订单”的分支。
实际代码使用参数绑定:
jdbc.update(
"INSERT INTO orders(id, amount_cents, currency) VALUES(?, ?, ?)",
order.id(), order.total().minorUnits(), order.total().currency());JdbcTemplate 管理 JDBC 资源并转换数据访问异常。参数绑定用于数据值;表名、列名、排序方向等 SQL 结构仍需来自受控选择,不能直接把用户字符串拼进去。Spring JDBC 核心操作
MyBatis 的 #{...} 通常形成预处理参数,${...} 执行文本替换。动态排序可以把用户选项映射为服务端允许的固定列名,而不是直接替换任意输入。MyBatis SQL 映射
不要把网络等待包进长数据库事务
事务内调用付款服务,数据库会在等待网络时继续占用连接,已经取得的锁也可能继续保持。远端超时还会产生结果未知,本地 rollback 无法撤回远端已完成的付款。
需要跨系统协作时,可以先保存本地状态和待执行记录,再通过独立任务执行远端操作,使用业务幂等键、状态查询和补偿处理失败。若业务要求更强的提交关系,应选择并验证适合的协议,不能通过在更多方法上加事务注解获得跨服务原子性。
用数据库结果比较成功、失败与恢复
启动真实适配器测试
下载 工程质量实验,解压后进入 quality-engineering。环境为 Linux amd64、Bash、Docker Engine,当前普通用户拥有 Docker 权限和目录写权限。
bash run-docker.sh -Dtest=TransactionTest -Dsurefire.failIfNoSpecifiedTests=false
test -f verification/target/surefire-reports/example.quality.verification.TransactionTest.txt || exit 1脚本使用 Maven 3.9.12 与 Java 17,以宿主 UID/GID 构建;依赖版本由 Spring Boot 4.1.1 BOM 管理。每个测试建立独立 H2 内存库,通过 JDBC 执行 SQL,事务由 Spring 管理。
TransactionTest 预期 3 项通过。Java 25 对照方式为:
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25 \
bash run-docker.sh -Dtest=TransactionTest -Dsurefire.failIfNoSpecifiedTests=false第二次写入用真实列约束拒绝
测试建表时,有意让审计 ID 列比订单 ID 列更短:
CREATE TABLE orders (
id VARCHAR(64) PRIMARY KEY,
amount_cents BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL
);
CREATE TABLE audit (order_id VARCHAR(8) NOT NULL);这是一种可观察的失败夹具,生产表应根据实际约束正确设计,不应保留这种长度不一致。
第一项使用短 ID o-1,两次插入成功。随后直接查询两张表,确认各有一行,订单金额为 1230。
第二项使用 too-long-id,第一条 INSERT 可接受,第二条因 audit.order_id 长度超限而抛出 DataIntegrityViolationException。测试要求最底层错误包含 ORDER_ID,再查询两张表都为零,最后用 o-2 重试有效用例,两张表恢复为各一行。
同一事务
INSERT orders 成功
↓
INSERT audit 违反列约束
↓
异常离开事务回调 → rollback
↓
orders = 0,audit = 0去掉事务时,第一条写入会留下
第三项把 Transactions 替换成 Runnable::run,保留完全相同的两个 JDBC 适配器。回调只是直接执行,此时数据操作走自动提交路径。
审计插入仍然失败,但订单表已经有一行,审计表为零。测试只删除这个明确的失败订单,再恢复真实事务适配器,使用 o-3 验证后续用例成功。
| 实验路径 | orders | audit | 检查的含义 |
|---|---|---|---|
| 正常事务,两次写入成功 | 1 | 1 | 用例正常提交 |
| 事务内第二次写入失败 | 0 | 0 | 第一条写入随用例回滚 |
| 没有用例事务,第二次写入失败 | 1 | 0 | 自动提交保留了部分结果 |
| 恢复后运行有效用例 | 1 | 1 | 连接与事务可继续使用 |
每次异常后都重新查询两张表,核对哪些写入已经提交。H2 能验证本地事务组合;部署到 PostgreSQL 或 MySQL 前,仍需使用目标引擎验证其 SQL 方言、约束、隔离级别、锁等待和驱动配置。
查询、并发和异常怎样继续分工
按用例控制数据规模
Repository 返回无限 List,会把数据库规模直接变成应用内存压力。列表查询应约定页大小上限、稳定排序及必要过滤;批量操作明确单批条数和失败恢复方式。
ORM 关联可能产生 N+1 查询,也可能因一次联接抓取多个集合而放大结果行。检查实际 SQL 数量、返回行数和执行计划,再选择投影、批量加载或合理抓取方式。单元测试中几条记录的快速执行,不能说明生产规模下读取成本合理。
跨模块查询需要明确谁提供公开数据。报表可以有专用读取模型,但不应由任意业务服务直接读写另一个模块内部表,以免表结构调整影响所有调用方。
并发更新检查影响行数
状态转换可以在 SQL 中携带旧状态或版本条件:
UPDATE orders
SET state = ?, version = version + 1
WHERE id = ? AND version = ?;这一段是版本更新形态示例,不是实验 orders 表的可执行 SQL;实际使用需要先建立相应列及并发测试。影响零行可能表示版本已变化或对象不存在,应按业务查询区分并返回冲突结果,而不是继续报告修改成功。
数据库唯一约束负责阻止并发重复键。应用层先查“是否存在”可以改善反馈,但两个请求可能同时查到不存在,所以最终写入仍要处理约束失败。
在适当位置翻译异常
数据访问层保留底层原因,应用服务把已知约束转换为业务可理解的结果,例如订单号冲突。HTTP 入口再选择状态码和公开错误结构,避免把 SQL、连接地址或完整堆栈返回给客户端。
不要捕获所有异常后返回空集合、false 或 null;调用方会丢失“确实没有数据”和“查询失败”的区别。也不要每层都打印同一堆栈,导致一次故障出现多份重复错误。
| 表现 | 优先检查 | 修复后的验证 |
|---|---|---|
| 日志报错但部分数据留下 | 是否同 DataSource、同事务,异常是否被吞掉 | 第二次写入故障后查询所有相关表 |
| 事务注解存在却不回滚 | 代理路径、异常类型、事务管理器 | 经真实 Spring 调用路径执行负例 |
| 服务方法不断膨胀 | 是否混入 SQL、格式转换及多种业务流程 | 按具体变化拆分,保留用例级测试 |
| Repository 抽象难以实现 | 是否返回了另一种存储专属查询器 | 改为业务需要的操作或投影 |
| 测试通过,线上锁等待严重 | 真实数据量、事务时间、索引和并发 | 在目标数据库上复现竞争与超时 |
实验没有长期服务,容器结束后数据库随 JVM 退出,测试报告保留在解压目录。查询、事务、对象转换的测试可以独立定位错误,再由完整用例测试确认它们组合后的结果。
权威资料与规范地址
仓库抽象与 SQL 映射
- Spring Data 仓库概念:https://docs.spring.io/spring-data/jpa/reference/repositories/core-concepts.html
- Spring JDBC:https://docs.spring.io/spring-framework/reference/data-access/jdbc/core.html
- MyBatis SQL 映射:https://mybatis.org/mybatis-3/sqlmap-xml.html
