Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions gaps/GAP-55/DRAFT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Identity: @fetchable

> [!NOTE]
> This is one of a pair of companion proposals. `@fetchable` builds on **Identity:
> @strong** ([GAP-54](../GAP-54/README.md)), which marks types
> as having an identity. The two are designed to be read together but may be
> adopted independently.

```
directive @fetchable(field_name: String!) on OBJECT | INTERFACE
```

## Introduction

:: This document specifies the `@fetchable` schema directive, which declares that
a type can be independently (re-)fetched from a type-specific root field, given a
field value that identifies it.

A `@fetchable` type can be retrieved on its own — without replaying the operation
that originally produced it — through generated root fields keyed on a
type-unique identifier. This enables refetching, cache eviction, and
optimistic-update tooling to re-resolve an object in isolation.

`@fetchable` builds on the companion
[`@strong`](../GAP-54/README.md) directive: a type can only be fetched by an
identifier if it has one, so every `@fetchable` type must also be `@strong`. The
two need not, however, use the same field — `@fetchable(field_name: "<field>")`
does not have to match `@strong(field_name: "<field>")`.

This is an alternative form of fetchability to the
[Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm).
`@fetchable` may be useful when:

- You want to improve performance by avoiding costly `node(id: $id)` root field
resolution in favor of type-specific root fields.
- You want to improve performance by separating a field for large tokens required
for fetchability from a small field for hashed identity.
- You have already created an expensive or non-identifying `id` field that you
cannot migrate away from.

For a `@fetchable` type `<Type>`, the schema is guaranteed to contain a
single-item root field, a multi-item root field, and an edge type:

```graphql
type Query {
fetch__PhotoStory(id: ID!): PhotoStory
multifetch__PhotoStory(ids: [ID!]!): [PhotoStoryMultifetchEdge!]!
}

type PhotoStoryMultifetchEdge {
node: PhotoStory
node_id: ID!
}
```

where `node_id` echoes the requested `id` (equal to the resolved `node`'s
`<field_name>` value when present).

**Example**

```graphql
# `Story` is guaranteed to have an identity, but that identity may be expressed
# by a different field across implementations.
interface Story @strong {
text: String
}

# `EphemeralStory` has an identity (so it can be merged in a normalized cache)
# but is not, on its own, fetchable.
type EphemeralStory implements Story @strong(field_name: "cache_id") {
cache_id: ID!
text: String
}

# `PhotoStory` is fetchable: it can be re-resolved on its own via
# `Query.fetch__PhotoStory(id:)`.
type PhotoStory @strong(field_name: "id") @fetchable(field_name: "id") {
id: ID!
text: String
photo_url: String
}
```

**Use Cases**

- Refetching, cache eviction, and optimistic-update tooling may re-resolve a
`@fetchable` object on its own, rather than re-running the original operation.
- Batch loaders may use `multifetch__<Type>` to resolve many objects of a type in
a single round trip.
- Code generators may emit refetch queries only for the types that declare
fetchability.

With the above example, having read a `PhotoStory`'s `id` in an earlier response,
we can re-resolve just that story without replaying the original query:

```graphql
query RefetchPhotoStory($id: ID!) {
fetch__PhotoStory(id: $id) {
id
photo_url
}
}
```

To re-resolve many stories at once, `multifetch__PhotoStory` returns one edge per
requested id, in the same order and with the same length as the input list. Each
edge's `node_id` echoes the requested id (and equals `node`'s
`@fetchable(field_name:)` value when resolved), so results can be correlated back
to the requested ids even when a `node` is {null}:

```graphql
query RefetchPhotoStories($ids: [ID!]!) {
multifetch__PhotoStory(ids: $ids) {
node_id
node {
photo_url
}
}
}
```

## Relationship to @strong

When using `@strong` every `@fetchable` type must also be `@strong`. The `@fetchable(field_name:)`
need not match the `@strong(field_name:)`: identity (used for merging objects in
a normalized cache) and fetchability (used for re-resolution) may be backed by
different fields. This is useful, for example, when a small hashed field is used
for identity while a separate, larger token field is required to fetch the
object.

## Generated root fields

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For an interface that is @fetchable, what are its generated fields? Are the root fields generated for only the concrete types, or for the interface as well?

Using your example above for Story, is fetch__Story(id:) available or just fetch__PhotoStory(id:)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just realized this is in the README. Should it be included here as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably!

But yes: each type (Object or Interface) that is @fetchable gets its own Query fields.

This is important: you may need the identity for interface fetchability to encode the underlying type, to make resolving across a cloud of types efficient, whereas the default fetchable field for a given object could be its standard @strong field.


For each `@fetchable` type `<Type>`:

- a single-item query field, `Query.fetch__<Type>(id: ID!): <Type>`, exists.
- a multi-item query field,
`Query.multifetch__<Type>(ids: [ID!]!): [<Type>MultifetchEdge!]!`, exists.
- `type <Type>MultifetchEdge { node: <Type>, node_id: ID! }` exists.

`field_name` is always required. It names the field whose value can be passed to
the generated `Query.fetch__<Type>(id:)` and `Query.multifetch__<Type>(ids:)`
fields to (re-)fetch the object with the exact same _identity_.

`multifetch__<Type>(ids: $ids)` must return exactly one `<Type>MultifetchEdge`
per input `id`, in the same order as the input `ids`: the result list always has
the same length as the input list. For each edge:

- `node` is the resolved `<Type>`, or {null} if the corresponding `id` cannot be
resolved.
- `node_id` is the `id` from the input list — the original requested key,
preserved even when `node` is {null}. When `node` is non-null, `node_id` equals
`node`'s `@fetchable(field_name:)` value.

The nullable `node` within a non-null edge is precisely what lets a caller
correlate every requested `id` to its result — by position and by `node_id` —
even for ids that did not resolve.

## @fetchable on Object types

For Object types, `@fetchable(field_name: "<field>")` is required: the referenced
field must exist and be typed `<field>: ID @semanticNonNull` or `<field>: ID!`.

## @fetchable on Interface types

`field_name` is required on an `@fetchable` interface, just as it is on an
`@fetchable` object. All types implementing an `@fetchable` interface must
themselves be `@fetchable`, but the interface and its implementations each
specify their own `field_name` and _need not_ share the same value.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused by this section. If field_name is required on all @fetchable interfaces, but each type can specify their own field_name, how does it extend the interface correctly?

Here is an example that seems like it would be valid according to this section:

# "field_name" is required, so it needs to be provided with
# a value
interface Story @fetchable(field_name: "id") {
  id: ID!
}

# field_name can be different than its interface
type PhotoStory implements Story @fetchable(field_name: "cache_id") {
  # id required by the interface
  id: ID!
  cache_id: ID!
}

Am I understanding this correctly? If so, what is the value in having different @fetchable field names?

This ties into the question for generated root fields. If root fields are only generated for object types, why is field_name required for @fetchable interfaces?

Clarification on my misunderstand would be helpful 🙂

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just realized this is included in the README. Should it be mentioned here as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if root fields are only generated for object types,

not just object types


## New Implementation Recommendations

This specification _matches behavior in existing implementations_: as this
specification is a result of iterative, in-production implementations, it
describes what _is_ rather than what _ought to be_.

If you're creating a new GraphQL implementation, you should, if possible:

- Prefer a single field for both `@strong(field_name:)` and
`@fetchable(field_name:)` unless a separate fetch token is genuinely required.
- Ensure `Query.fetch__<Type>` and `Query.multifetch__<Type>` are generated
consistently for every `@fetchable` type, rather than hand-authored.
184 changes: 184 additions & 0 deletions gaps/GAP-55/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# GAP-55: Identity: @fetchable

> [!NOTE]
> This proposal has a companion: **Identity: @strong**
> ([GAP-54](../GAP-54/README.md)), which `@fetchable` builds on.

## Overview

This proposal defines the **`@fetchable`** schema directive, which declares that
a type can be independently (re-)fetched from a type-specific root field given a
field value that identifies it.

`@fetchable` builds on the companion **`@strong`** directive (see
[Identity: @strong](../GAP-54/README.md)): every `@fetchable` type must also be
`@strong`, though the two directives need not reference the same field — identity
and fetchability may be backed by different fields.

For a `@fetchable` type `Type`, the schema guarantees generated root fields:

- `Query.fetch__Type(id: ID!): Type` — single-item fetch
- `Query.multifetch__Type(ids: [ID!]!): [TypeMultifetchEdge!]!` — batch fetch
- `type TypeMultifetchEdge { node: Type, node_id: ID! }`

Given the schema:
```
type User @strong(field_name: "id") @fetchable(field_name: "key") {
id: ID!
key: ID!
}

type Query {
fetch__User(id: ID!): User
multifetch__User(ids: [ID!]!): [UserMultifetchEdge!]!
}

type UserMultifetchEdge {
node: User
node_id: ID!
}
```

and a `User.key` value, `@fetchable` guarantees that the following query will always produce the same response when provided the same `$key`:

```
query FetchUser($key: ID!) {
fetch__User(id: $key) {
id
key
}
}
```
While `User.id` may be different from `User.key`, both values must stay consistent across requests.

## Motivation

This proposal documents how `@fetchable` is already defined and used, in
production. `@fetchable` exists to let a client re-resolve a single object on its
own — for refetching, cache eviction, pagination repair, and optimistic-update
rollback — without replaying the operation that originally produced it.

`@fetchable` builds directly on [`@strong`](../GAP-54/README.md), and the two
divide a single concern that [Global Object Identification](https://relay.dev/graphql/objectidentification.htm)
conflates. Identity (`@strong`) tells a client *whether two values are the same
entity*; fetchability (`@fetchable`) tells it *how to retrieve that entity
again*. The Global Object Identification spec couples both into one `id` field
resolved through a single `node(id:)` root field, which carries three costs
`@fetchable` avoids:

- **Service resolution cost**: A single polymorphic `node(id:)` must dispatch across
every `Node` type, which is often slower and harder to optimize than a
type-specific root field. `@fetchable` generates `Query.fetch__<Type>(id:)` and
`Query.multifetch__<Type>(ids:)`, so each type is fetched through a direct,
individually optimizable path.
- **Token size**: Because `node(id:)` keys on the same `id` used for identity, an
object that needs a large retrieval token must carry that token *as* its
identity, bloating every cache key. `@fetchable` lets identity and retrieval be
backed by *different* fields — exactly the `id` (identity) versus `key`
(retrieval) split shown above: a small hashed field can serve as the `@strong`
identity while a separate, larger token field serves as the `@fetchable` key.
- **Client type resolution**: `Node` is an abstract type, and requires explicit type-discrimination like `... on ActualType { ... }`
to actually use the value. This risks empty non-null responses when `node` resolves
to a different type. It also makes schema evolution more challenging: migrating a type from an Object to an Interface
is difficult when clients rely on a `__typename` field to determine whether the spread underneath `node` is fulfilled.

Because `@fetchable` is per-type and explicit, code generators can emit refetch
queries only for the types that actually declare fetchability, rather than
assuming every `Node` is independently retrievable.

## Relationship to prior art

This is an alternative form of fetchability to the
[Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm)
(the `Node` interface and `node(id:)` root field).

Related discussions and prior art:

- [Global Object Identification](https://relay.dev/graphql/objectidentification.htm).
- [Apollo Federation entities and `@key`](https://www.apollographql.com/docs/federation/entities/).
- Companion proposal: [Identity: @strong](../GAP-54/README.md).

**Reference implementation.** Relay already carries (partial, partly
undocumented) support for `@fetchable`:

- Relay's directive docs reference it under
[`@refetchable(..., preferFetchable:)`](https://relay.dev/docs/api-reference/graphql/graphql-directives/):
the flag makes the compiler "prefer generating `fetch_MyType(): MyType`
queries … useful for schemas that have adopted the `@strong` and `@fetchable`
server annotations".
- The `@fetchable(field_name:)` directive is defined in the Relay compiler —
[`compiler/crates/schema/src/flatbuffer.rs`](https://github.com/facebook/relay/blob/main/compiler/crates/schema/src/flatbuffer.rs).
- Relay generates per-type fetch queries from `@fetchable` via
[`fetchable_query_generator.rs`](https://github.com/facebook/relay/blob/main/compiler/crates/relay-transforms/src/refetchable_fragment/fetchable_query_generator.rs).
- `@fetchable` on interfaces is shown in the
[Relay 15 release notes](https://relay.dev/blog/2023/03/30/relay-15/).

## Status

**Proposal.** Initial draft; not yet sponsored.

## Challenges and drawbacks

`@fetchable` inherits the adoption caveats of [`@strong`](../GAP-54/README.md),
and adds a few of its own.

**Generated field naming and collisions**: `@fetchable` reserves the
`fetch__<Type>`, `multifetch__<Type>`, and `<Type>MultifetchEdge` names. The
embedded `__` is the convention that keeps these from colliding with
user-authored fields (which must not contain `__`), but the prefix is not
configurable in this spec, and an implementation must still guard against
collisions between generated names and any other generated or hand-authored
schema element.

**Two identity-like fields to keep in sync**: allowing
`@fetchable(field_name:)` to differ from `@strong(field_name:)` means a type can have
two related-but-distinct fields to represent identity.
Prefer a single field for both unless a separate fetch token is genuinely
required.

**The `MultifetchEdge` wrapper**: `multifetch__<Type>` returns a wrapper edge
type (`{ node, node_id }`) rather than a plain `[<Type>]`, purely so an
unresolved `id` can be correlated back to its request by position and by
`node_id`. This is extra schema surface, and the nullability of `node` versus
`node_id` is subtle: `node` may be {null} for an id that does not resolve, while
`node_id` always echoes the requested id. Implementations that support nullable
list items could express this without the wrapper, but the wrapper is required if
your client cannot include null list items: you don't want to compact out null edges
and end up with differently-sized input and output lists.

**Fetchable interfaces with divergent `field_name`s**: an `@fetchable` interface
and each of its implementations may declare *different* `field_name`s. It's important
not to assume we can use the *concrete type's* fetchable field with the *interface*'s root fetch field.

For instance with:
```
interface Animal @fetchable(field_name: "animal_id") {
name: String
animal_id: ID!
}

type Dog implements Animal @fetchable(field_name: "id") {
name: String
id: ID!
animal_id: ID!
}

type Query {
fetch__Animal(id: ID!): Animal

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would a client know to use this field since it only knows about concrete types? (e.g. __typename will never be Animal).

@mjmahone mjmahone Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client knows it is requesting an interface: the random_animal field returns an Animal therefore the client can know to use the fetch__Animal root field.

This may require manually knowing if there is no compiler to automatically thread these through. We always have a compiler step for producing queries for any client with a store, so this is not a problem for us. Alternatively, you could use the __fulfilled meta-field (@s3wasser may have a GAP coming to describe this, though we've described it in different PRs in the past: https://rfcs.graphql.org/rfcs/879/) to answer which specific interfaces were asked for.

@mjmahone mjmahone Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also just logistically: we recommend against using __typename for any amount of business logic. The only thing we sometimes feel forced into using the explicitly returned-from-the-server __typename field for is creating a globally unique key for our client store out of a per-type key. Using it for "is this spread fulfilled" is in our opinion a bad practice, see: https://graphql.org/conf/2026/schedule/7d3a7e26a24ad1ef28c0c9a913dd69bb/?name=The%20Case%20Against%20__typename%20-%20Sabrina%20Wasserman,%20Meta%20Platforms%20Inc

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the "Apollo Client" way of thinking is so engrained in me which is why I have a hard time thinking about how these fit together, especially the lack of schema knowledge or compiler to help on that end of things. Thanks for the clarification!

fetch__Dog(id: ID!): Dog
random_animal: Animal!
}
```
If we try to get a Dog by passing the `animal_id` from `Query.random_animal.animal_id` in an operation like:
```
query FetchDog($id: ID!) {
fetch__Dog(id: $id) {
name
id
}
}
```
we cannot guarantee the `Query.fetch__Dog` field can resolve based on the `animal_id`.

Instead, we must use `Query.fetch__Animal` when passing an `Animal.animal_id` or `Dog.animal_id`,
but we can specifically request a `Dog` response with `Query.fetch__Dog` via a `Dog.id`.
16 changes: 16 additions & 0 deletions gaps/GAP-55/metadata.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
id: 55
title: "Identity: @fetchable"
summary: >
A schema directive declaring that a type can be independently (re-)fetched via
generated type-specific root fields (`fetch__Type`, `multifetch__Type`). Builds
on the companion `@strong` directive — every `@fetchable` type must be `@strong`.
status: proposal
authors:
- name: "Matt Mahoney"
email: "mahoney.mattj@gmail.com"
githubUsername: "@mjmahone"
sponsor: "@TBD"
discussion: "https://github.com/graphql/gaps/pull/55"
related:
- 49
- 54