GraphQL Mutation 与错误语义
理解 GraphQL Mutation 作为显式写操作入口,并区分 Schema 校验、Runtime Validation、业务规则、数据库约束与错误翻译的职责。
[!info] related notes
- 所属 MOC: graphql-moc
- 相关概念: constraint、composite-unique-constraint
- 易混淆概念: graphql-schema
- 相关资源: graphql-apollo-client
GraphQL Mutation 与错误语义
为什么要一起理解
Mutation 不只是“GraphQL 版 POST”。真正需要掌握的是:写操作如何表达副作用,输入在哪些层被验证,失败怎样翻译,以及客户端怎样让缓存重新与服务端一致。
Mutation 是什么
GraphQL 约定把可能改变服务端状态的操作放在 Mutation 根类型:
type Mutation {
updateProfile(input: UpdateProfileInput!): UpdateProfilePayload!
}
客户端:
mutation UpdateProfile($input: UpdateProfileInput!) {
updateProfile(input: $input) {
profile { id name }
}
}
Query 与 Mutation 最大的语义差异不是 HTTP method,而是:Mutation 明确告诉系统这里存在业务副作用。
Mutation 也不等于数据库 INSERT/UPDATE/DELETE。它可以表示:
createAssessment
sendEmail
cancelRun
generateReport
logout
更接近 Command / Use Case 的 API 入口。
为什么写操作通常使用 Input
input UpdateProfileInput {
name: String
height: Int
}
Input Object 可以让参数结构稳定演进,避免根字段长期堆积大量位置参数。
输入验证不是一层完成
假设:
input CreateUserInput {
email: String!
age: Int!
}
业务要求:
email 必须是合法邮箱
age >= 18
email 不能重复
不同层的职责不同。
1. GraphQL Schema:结构与基础类型
默认 String! / Int! 可以保证:
字段是否存在
是否允许 null
值是否属于 GraphQL 基础类型
输入结构是否符合 Schema
但默认不会自动保证:
email 真的是合法邮箱
age >= 18
score 在 0..100
除非额外使用自定义 Scalar、Directive 等机制。
2. Runtime Validation:格式与边界
例如 Zod:
const inputSchema = z.object({
email: z.string().email(),
age: z.number().int().min(18),
})
这里适合处理格式、范围以及 transport/application boundary 能明确判断的输入条件。
3. Service / Domain:业务不变量
例如:
当前状态是否允许修改?
用户今天是否还能创建 Assessment?
当前 actor 是否拥有该资源?
这些需要业务上下文,应该由 Service / Domain / Policy 负责。
4. Database Constraint:最终持久化边界
例如:
UNIQUE(email)
CHECK (...)
FOREIGN KEY (...)
NOT NULL
Service 可以提前检查 email 是否存在以提供友好错误,但不能替代 UNIQUE(email)。
并发场景:
Request A → 检查不存在
Request B → 检查不存在
A → INSERT
B → INSERT
只有数据库 UNIQUE 才能在最终持久化边界阻止重复。
所以不是“重复做同一件事”,而是:
GraphQL Schema
→ shape / primitive type
Runtime Validation
→ format / range
Service / Domain
→ business invariant / authorization
Database Constraint
→ concurrency-safe persistence invariant
GraphQL 错误要分层
1. Transport 错误
例如连接失败、网关超时、HTTP 无法到达 GraphQL Server。
此时通常连 GraphQL execution 都没真正完成。
2. Request / Validation 错误
查询不符合 Schema:
query {
user {
fieldThatDoesNotExist
}
}
这类错误发生在执行 resolver 之前。
3. Execution 错误
Resolver 真正执行后发生异常,例如下游服务失败或非空字段无法满足契约。
GraphQL 允许返回:
{
"data": {
"user": {
"name": "Sean",
"diagnosis": null
}
},
"errors": [
{ "message": "diagnosis service unavailable" }
]
}
也就是说:data 与 errors 可以同时存在。
这就是 GraphQL 很重要的“部分成功”语义。
4. 业务错误
例如:
用户名已存在
当前状态不允许修改
没有该资源的写权限
它们不一定应该全部变成未知的 resolver exception。稳定业务失败可以进入明确的 payload/union/code,让客户端可靠分支处理。
不要把数据库原始错误直接泄漏出去
例如 Prisma/PostgreSQL 报:
P2002
Unique constraint failed ...
不应默认直接透传给 GraphQL Client。
更合理的链路:
PostgreSQL / Prisma error
↓
Persistence Adapter / Repository translate
↓
EmailAlreadyExistsError
↓
GraphQL error mapping
↓
public error code + safe message
例如:
{
"errors": [
{
"message": "Email already exists",
"extensions": {
"code": "EMAIL_ALREADY_EXISTS"
}
}
]
}
这样 API Contract 不被具体 ORM / DB 错误格式绑死,也减少内部实现信息泄漏。
Nullability 会影响错误传播
如果某字段声明为非空:
name: String!
但 resolver 返回 null,GraphQL 必须把这个契约违例向上冒泡,直到找到允许 null 的父级。
所以 ! 不只是“类型更严格”,还直接影响运行时错误传播范围。
Mutation 后还没结束
写成功之后,客户端缓存可能仍然是旧数据。
因此完整链路是:
Mutation
↓
Server state changed
↓
Mutation response
↓
Client cache reconcile
↓
UI becomes consistent
在 Apollo Client 中,Mutation 返回具有稳定 identity 的实体时,normalized cache 可以 merge 对应 entity;列表 membership、派生查询或响应信息不足时,仍可能需要手动 cache update 或 refetch。
在 TanStack Query 中,也可以直接 setQueryData / optimistic update;invalidateQueries + refetch 是常见的重新获取 authority 的策略,而不是唯一能力。
判断准则
看到 GraphQL 写操作时依次问:
Schema 能验证什么?
→ Runtime validation 还要验证什么?
→ Service / Domain 要守什么业务规则?
→ Database Constraint 要守什么最终不变量?
→ 底层错误怎样翻译成稳定 API 错误?
→ Mutation 后客户端 projection 怎样重新一致?
只有先分层,验证、重试、用户提示、监控、授权和缓存恢复才不会混在一起。