好的 Schema 是 GraphQL 服务的灵魂。它不仅是前后端共享的契约,更是一份团队架构决策的可执行文档。当团队从"能跑通的 Schema"走向"经得起三年演进的 Schema"时,接口(interface)、联合类型(union)、输入对象(input)、中继连接(Relay Connection)这些高级建模工具就会从"偶尔用到"变成"日常标配"。本文从工程实践出发,系统讲解 Schema 设计中容易忽略却决定长期质量的八个关键领域,并在最后一节给出可直接用于评审的设计清单。
一、Schema 设计的整体原则
1.1 面向使用场景而非数据表
初学 GraphQL 时最常见的误区,是把数据库表结构原样映射成 Object Type。这种做法短期内开发最快,却把存储结构直接暴露给了客户端,一旦底层表拆分、字段重命名,破坏性变更就会波及所有调用方。
正确的思路是:Schema 是领域模型的投影(Projection),不是数据库的投影。设计每个 type 时,先问三个问题:客户端在什么场景下读取它?它是「事实」还是「派生」?没有客户端需要它时,它是否应该进入公开 Schema?
1.2 契约优先(Schema-first)与代码优先(Code-first)
Schema 的组织方式分两大流派:
| 流派 | 代表工具 | 优势 | 劣势 |
|---|---|---|---|
| Schema-first | Apollo Server + graphql-tools | Schema 一目了然,便于评审与跨团队协作 | SDL 与 resolver 需要手工对齐,改字段容易漏 resolver |
| Code-first | Pothos、GraphQL Yoga + Zod | 类型推导无缝衔接,一处定义处处使用 | Schema 分散在代码里,评审成本高 |
大型团队通常采用 Schema-first 作为契约管理方式(Schema 文件进入 git 评审流程),而把**类型生成(codegen)**作为桥接手段,让代码与 SDL 自动保持一致(详见第八节)。
Schema 是契约,契约必须可以被评审;类型生成器负责消除「契约与实现漂移」,而不是替代契约本身。
二、接口(interface)与联合类型(union)的建模
2.1 interface:共享字段的抽象契约
当多个类型拥有一组语义完全一致的公共字段时,应当提取 interface。典型场景是「可评论的内容」「可收藏的资源」「可支付的对象」。
interface Commentable {
id: ID!
content: String!
author: User!
createdAt: String!
}
type Post implements Commentable {
id: ID!
content: String!
author: User!
createdAt: String!
title: String!
slug: String!
}
type Video implements Commentable {
id: ID!
content: String!
author: User!
createdAt: String!
duration: Int!
coverUrl: String
}
提取 interface 的价值在于:客户端可以跨类型统一消费公共字段、Schema 语义更精确(收敛多个重复字段)、并为联邦与多态(@key 实体)奠定基础。
2.2 union:无公共字段的多态返回
与 interface 不同,union 允许成员类型完全没有公共字段,适合表达「一个操作的返回结果是多种可能」:
union SearchResult = User | Post | Product | Organization
type Query {
search(keyword: String!, first: Int!): SearchResultConnection!
}
union 的标准查询模式是内联片段(inline fragment):
query Search {
search(keyword: "graphql", first: 10) {
edges {
node {
__typename
... on User {
name
avatarUrl
}
... on Post {
title
excerpt
}
... on Product {
name
price
}
}
}
}
}
2.3 interface 还是 union:选择矩阵
| 判断依据 | 选择 interface | 选择 union |
|---|---|---|
| 类型间公共字段 | 有明显公共字段 | 几乎没有公共字段 |
| 客户端消费方式 | 多态但统一(同字段不同值) | 互斥分支(不同字段集) |
| 将来扩展性 | 需要频繁新增实现类型 | 分支固定、变化少 |
| 典型场景 | 评论、点赞、Feed 条目 | 搜索结果、支付回调结果 |
2.4 多态解析:__typename 与 resolveType
服务端在解析多态字段时,必须告诉 GraphQL 运行时「这个抽象类型的具体类型是什么」。Apollo Server 中可以为接口/联合类型注册 resolveType,也可以依赖运行时自动推断(当解析器返回的对象的 __typename 或构造函数名与 schema 类型名一致时)。
// Apollo Server 显式 resolveType
const resolver = {
SearchResult: {
__resolveType(obj: any) {
if (obj.kind === 'user') return 'User';
if (obj.kind === 'post') return 'Post';
return null; // 无法识别时返回 null,向父字段传播
}
}
};
三、输入对象设计:input、oneOf 与复用
3.1 input type 的命名与边界
所有入参类型应显式命名为 XxxInput,并遵循「一个 mutation 一个主 input」的原则:
input CreateUserInput {
email: String!
password: String!
nickname: String!
profile: UserProfileInput
}
input UserProfileInput {
bio: String
website: URL
location: String
}
需要注意 input type 与 output type 的命名域是共用的——GraphQL 不允许 User 同时是 Object 与 Input。因此常用 UserInput 与 User 区分。也不要让同一个 input 承担「创建」与「更新」双重职责,建议拆为 CreateXxxInput 与 UpdateXxxInput,更新场景中可选字段一律 nullable,表示「不修改」。
3.2 oneOf:互斥输入约束
GraphQL 规范 2021 年引入 @oneOf 指令,用于声明「输入对象恰好只能有一个字段被提供」。这在「按不同类型搜索」「多条件定位资源」场景中非常实用:
input UserWhereUniqueInput @oneOf {
id: ID
email: String
phone: String
}
type Query {
user(where: UserWhereUniqueInput!): User
}
启用 @oneOf 的输入对象:
- 所有字段必须为 nullable;
- 请求时必须且只能提供一个非 null 字段,否则返回校验错误;
- 支持器(如 graphql-js 的
specified校验)会在请求解析阶段直接报错,避免 resolver 里手写互斥判断。
注意 @oneOf 指令需要 @link 引入 https://specs.graphql.org/draft/2025-01 或通过 @oneOf directive 定义,部分网关(Apollo Router)对 @oneOf 的透传支持有版本要求,落地前先在目标运行时验证。
3.3 输入校验的分层策略
| 校验层 | 负责内容 | 实现方式 |
|---|---|---|
| GraphQL 类型系统 | 类型、非空、枚举值、标量格式 | Schema 定义(String!、Int、DateTime) |
@oneOf / 内置约束 | 互斥字段、list 长度 | 指令与 specified 规则 |
| 业务校验 | 唯一性、状态机合法性、权限 | resolver 内或独立校验层(Zod/Yup) |
| 数据库约束 | 外键、唯一索引 | 数据库层兜底 |
原则:能由类型系统表达的约束,绝不放进制程代码。输入越"窄",错误越早暴露,客户端体验越好。
四、命名约定与弃用策略
4.1 命名约定清单
一套团队级的命名约定能极大降低 Schema 的认知成本:
| 范畴 | 约定 | 示例 |
|---|---|---|
| 类型 | PascalCase,名词 | User、OrderItem |
| 字段/参数 | camelCase,动词+宾语 | createOrder、userById |
| 枚举值 | SCREAMING_SNAKE_CASE | PENDING、PAID |
| 输入类型 | XxxInput | CreateOrderInput |
| 连接类型 | XxxConnection / XxxEdge | UserConnection、OrderEdge |
| 返回封装 | 直接返回类型本身 | user(id) 返回 User 而非 { data: User } |
4.2 @deprecated 的正确姿势
弃用(deprecation)是 GraphQL Schema 演进的缓冲机制,它让破坏性变更降级为可预期变更:
type User {
id: ID!
name: String!
# 已迁移到 profile.displayName
displayName: String @deprecated(reason: "Use profile.displayName instead")
profile: UserProfile!
}
@deprecated 必须在 reason 中给出明确的替代路径。Apollo Studio / GraphQL Inspector 会统计每个弃用字段的实际使用量,只有使用量归零的字段才允许在下个大版本中删除。
五、中继连接规范(Relay Connection)
5.1 为什么需要 Connection
{ users(first: 10) { id name } } 这种朴素的列表返回在分页场景下有两个致命缺陷:
- 无法表达「是否还有下一页」「总共有多少条」;
- 数据插入/删除后,offset 分页会重复或遗漏数据。
Relay Connection 规范把列表抽象为 Connection -> Edge -> Node 三层结构:
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
PageInfo 的 hasNextPage / hasPreviousPage 让客户端无须先知道总条数即可实现「加载更多」;cursor 是不透明的游标,屏蔽了底层实现细节。
5.2 何时必须用 Connection
| 场景 | 是否推荐 Connection | 原因 |
|---|---|---|
| 列表可被客户端分页消费 | 是 | 游标分页 + 稳定的排序 |
内部聚合字段(如 order.items 通常 < 20 条) | 否 | 一次性返回,避免过度建模 |
| 无限滚动 / 订阅流 | 是 | 增量加载与去重 |
| 管理后台大列表 | 是 | 深度分页 + 排序过滤 |
原则:为"会变长、会被分页"的列表建模 Connection,为"固定且短"的列表保留数组。不要为了形式主义给每个数组字段套 Connection。
六、可空性策略:null vs error
6.1 null 的语义分歧
GraphQL 中 null 是一等公民,但 null 语义的含糊是团队分歧的最大来源。同一个 null 可能意味着:
- 值不存在(用户没有手机号);
- 值未知(系统尚未获取);
- 值不可见(权限不足,但不想暴露原因);
- 发生了错误但被吞掉。
可空性策略的第一步是消灭"万能 null",用类型系统显式表达语义:
| 语义 | 建模方式 |
|---|---|
| 必填且永远存在 | String! |
| 可选、允许不存在 | String |
| 未知/加载中 | union Value = ActualValue | LoadingState(高级) |
| 权限不可见 | @authenticated + 返回 null,配合文档说明 |
| 发生了错误 | 抛出错误而非返回 null(见下) |
6.2 null propagation 的连锁效应
type Order {
id: ID!
# 如果 items resolver 抛错,整个 Order 变为 null
items: [OrderItem!]!
}
客户端收到 { data: { order: null } } 时,无法区分是订单不存在、还是订单存在但内部字段失败。这会导致前端整个区块渲染失败。因此:
- 顶层取数(root field)尽量 nullable:
order(id)返回Order(可 null),用data.order == null表达"未找到",而非抛错。 - 聚合/子字段尽量非空:
order.items用[OrderItem!]!,一旦内部出错宁可整单失败,也不要返回[null, {...}, null]这种半残数据。 - 业务规则冲突用
errors[]表达:可空性负责"缺数据",错误机制负责"操作失败"。
6.3 可空性决策矩阵
| 场景 | 建议 |
|---|---|
| 用户可选资料(昵称、头像) | nullable |
| 系统计算值(余额、价格) | 非空,出错抛错 |
外键关联(order.user) | 非空(数据库外键保证),除非有孤儿数据 |
| 列表字段 | 外层非空 [...]!,元素非空 [X!] |
| 排序/过滤返回空集 | 返回 [](非空数组)而非 null |
七、Schema 组织与模块化:SDL 拆分
7.1 按领域拆分 SDL 文件
当 Schema 超过 1000 行时,单一 schema.graphql 文件会成为合并冲突的重灾区。推荐按领域边界拆分:
graphql/
├── schema.graphql # 根 schema、顶层 Query/Mutation/Subscription
├── user.graphql # User 领域:type、enum、input、mutation
├── order.graphql # Order 领域
├── product.graphql # Product 领域
├── scalars.graphql # 自定义标量声明
└── directives.graphql # @deprecated、@auth 等自定义指令
Apollo Server 通过 typeDefs 数组加载多份 SDL 并自动合并(mergeTypeDefs 会做去重与冲突检测):
import { readFileSync } from 'node:fs';
import { gql } from 'graphql-tag';
import { mergeTypeDefs } from '@graphql-tools/merge';
const typeDefs = mergeTypeDefs([
gql(readFileSync('./graphql/schema.graphql', 'utf-8')),
gql(readFileSync('./graphql/user.graphql', 'utf-8')),
gql(readFileSync('./graphql/order.graphql', 'utf-8')),
]);
7.2 自定义标量(scalar)的建模
对于时间、URL、JSON 等基础类型,直接暴露 String 会让类型系统失去约束力。推荐引入自定义标量:
scalar DateTime
scalar URL
scalar JSON
scalar BigDecimal
import { GraphQLScalarType, Kind } from 'graphql';
const DateTimeScalar = new GraphQLScalarType({
name: 'DateTime',
description: 'ISO 8601 时间戳,如 2026-09-27T10:00:00+08:00',
serialize: (value) => value instanceof Date ? value.toISOString() : value,
parseValue: (value) => new Date(value),
parseLiteral: (ast) => ast.kind === Kind.STRING ? new Date(ast.value) : null,
});
使用 graphql-scalars 库可直接获得经过实战验证的标量,不必从零实现。注意:自定义标量的 serialize/parseValue 行为必须在文档中写清楚,否则客户端与服务器会产生时区、精度分歧。
7.3 SDL 拆分后的约束
- 每个领域的 mutation 前缀统一(
createUser、updateOrder),便于前端代码组织与网关鉴权。 - 顶层
schema.graphql只保留Query/Mutation/Subscription根字段与跨领域引用。 - 合并工具(
@graphql-tools/merge)默认会检测重复类型名冲突,但同名字段覆盖是静默的,需要在 CI 里跑graphql-inspector diff做校验。
八、类型安全生成(codegen)
8.1 GraphQL Code Generator 工作流
graphql-codegen 是消除「前端手写类型 + 后端手写类型」双份维护的标准方案。典型配置:
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: './graphql/**/*.graphql',
documents: ['./src/**/*.graphql'],
generates: {
'./src/generated/graphql.ts': {
plugins: ['typescript', 'typescript-operations', 'typescript-resolvers'],
config: {
scalars: {
DateTime: 'string',
URL: 'string',
JSON: 'Record<string, unknown>',
},
maybeValue: 'T | null | undefined',
},
},
},
};
export default config;
前端拿到的是查询驱动的精确类型:每个 operation 生成一个只包含其请求字段的返回类型(typescript-operations 插件),彻底告别手写 interface OrderDTO。
8.2 后端 resolver 类型安全
后端使用 typescript-resolvers 插件,为每个 resolver 生成签名:
import type { Resolvers } from '../generated/graphql';
export const resolvers: Resolvers = {
Query: {
user: async (_, args, ctx) => {
// args 已推导为 { id: string }
// 返回类型必须是 User | null
return ctx.loaders.userById.load(args.id);
},
},
};
类型安全带来的收益:字段漂移在编译期暴露、args 与返回类型自动同步、测试数据可校验。
8.3 codegen 的落地注意点
| 注意点 | 建议 |
|---|---|
| codegen 输出是否入库 | 建议提交到 git,保证 CI 与本地一致 |
| 自定义 scalar 的映射 | 在配置中显式映射,避免 any 泛滥 |
| resolver 上下文类型 | 通过 resolverTypeWrapper / ResolverContext 注入 ctx 类型 |
| 增量编译 | CI 里用 --watch 或增量模式,避免全量重建拖慢反馈 |
九、Schema 设计评审清单
9.1 评审清单(Checklist)
将以下条目作为 PR / Schema Review 的必查项:
- 每个 type 是否面向使用场景,而非直接映射数据表?
- 列表字段是否准确选用了 Connection 或数组?
- 多态字段是否用对了 interface / union?
- 所有
@deprecated字段是否都提供了reason与替代路径? - input 对象是否遵循
XxxInput命名,创建/更新是否分离? - 可空性语义是否明确(缺数据 vs 失败)?根字段是否可 null?
- 自定义标量是否在文档中说明了序列化规则?
- SDL 是否按领域拆分,顶层是否只保留根字段?
- 是否存在无人消费的字段(可用 usage report 校验)?
- 是否存在「两段式」字段名(如
userName在user对象里)这种冗余?
9.2 一次典型评审记录示例
以一次「新增评论功能」的 Schema 评审为例,常见问题与整改如下:
| 初版写法 | 问题 | 整改 |
|---|---|---|
comments: [Comment!]! 返回全部 | 列表可能上千条,无分页能力 | 改为 CommentConnection |
Comment.target: String! | 用字符串表达目标类型,丢失类型安全 | 改为 target: Commentable!(interface) |
addComment(content: String!, postId: ID!) | 未来需支持对视频评论,字段爆炸 | 改为 addComment(input: AddCommentInput!) |
| 删除字段直接下线 | 客户端仍在用,造成破坏性变更 | 先 @deprecated 两个版本周期 |
FAQ
Q1: interface 与 union 能混用吗?
可以。联合类型中的成员可以是 interface 的实现类型,也可以把 interface 本身作为 union 成员。例如 union FeedItem = Post | Video,其中 Post implements Node、Video implements Node。查询时 ... on Node 分支与 ... on Post 分支可以共存。
Q2: 使用 Connection 一定会增加复杂度吗?
会增加一次 edges/node/pageInfo 的包装,但对"会变长、会被分页"的列表是值得的。真正的问题是把所有数组都套 Connection——对 order.items(通常 < 20 条)这样的内聚字段应保留数组。判断标准是"客户端是否会基于它做分页交互"。
Q3: @oneOf 指令在生产可用吗?
graphql-js 与部分服务端已支持,但它在网关层(如 Apollo Router)与老版本客户端的透传兼容性仍存在差异。落地前应在目标运行时做冒烟测试,并确认不需要对不支持 @oneOf 的客户端降级。
Q5: codegen 生成的文件要不要提交到 git?
建议提交。虽然可以在 CI 中生成,但提交能让本地开发、代码导航、评审体验完全一致,避免"CI 生成版本与本地不同"的漂移。生成文件应有明确的头部注释,并加入 lint 的 ignore 列表。
一句话总结
Schema 设计进阶的本质是用 interface/union 精确表达多态、用 input/Connection 收敛输入与分页、用可空性策略区分"缺数据"与"失败"、用 codegen 让契约与实现永不漂移——每一项都在为"Schema 能安全演进"这一长期目标服务。
相关阅读
- GraphQL 基础:类型系统、查询语言与解析器机制
- GraphQL Schema 演进与版本控制:零破坏性变更策略
- 游标分页与中继连接:从 offset 到 cursor 的工程实践
- GraphQL Resolver 性能与 N+1 问题根治
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。