GraphQL Schema 设计进阶:接口、联合类型与可空性策略

GraphQL Schema 设计进阶实践:interface/union 抽象建模、input/oneOf 输入对象设计、命名与弃用策略、Relay 连接规范、null vs error 可空性策略、SDL 模块化组织与 codegen 类型安全生成。

好的 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-firstApollo Server + graphql-toolsSchema 一目了然,便于评审与跨团队协作SDL 与 resolver 需要手工对齐,改字段容易漏 resolver
Code-firstPothos、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_CASEPENDING、PAID
输入类型XxxInputCreateOrderInput
连接类型XxxConnection / XxxEdgeUserConnection、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 } } 这种朴素的列表返回在分页场景下有两个致命缺陷:

  1. 无法表达「是否还有下一页」「总共有多少条」;
  2. 数据插入/删除后,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 } } 时,无法区分是订单不存在、还是订单存在但内部字段失败。这会导致前端整个区块渲染失败。因此:

  1. 顶层取数(root field)尽量 nullable:order(id) 返回 Order(可 null),用 data.order == null 表达"未找到",而非抛错。
  2. 聚合/子字段尽量非空:order.items 用 [OrderItem!]!,一旦内部出错宁可整单失败,也不要返回 [null, {...}, null] 这种半残数据。
  3. 业务规则冲突用 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」更多文章

  1. GraphQL Mutation 设计实战:从语义命名到乐观更新
  2. GraphQL 持久化查询与生产安全:从 APQ 到白名单的完整方案
  3. 游标分页与中继连接:从 offset 到 cursor 的工程实践