New semantics to avoid "prop drilling" with overloaded keys
This is inspired from a customer use case. They're doing server-driven UI and have a subgraph for returning "blocks" of user interface elements. The goal is avoiding the "data" subgraphs from knowing anything about "presentational" concerns, but this is challenging when we need to thread presentational context through a hierarchy of blocks.
Example
Notice that we have to add template { id } to entity keys in both subgraphs. This is the "leak" between the data and presentational boundaries.
-
Experience subgraph
- Returns a list of complex "page blocks" like product carousels. The blocks are associated with a data concern like a "product category".
- It also returns a list of "component blocks" with "leaf" UI elements.
- The leaf elements must know about a "template context" to determine the exact order and structure of a component. This should be strictly a presentational concern, but it leaks into the keys and other subgraphs types.
-
Data subgraph
- This subgraph should care only about Product data and listing/pagination types. But the only way to "thread" the template context from the "page blocks" through the products data into the "leaf blocks" is to overload the keys with the template data.
- I structured the SDL to nicely group the presentational concerns. But the real problem is that the resolvers have to forward the data from the category to the edge, which is easy to mess up.
Example operation
query HomePage($url: String!) {
page(url: $url) {
__typename
...ProductCategoryBlockFragment
}
}
fragment ProductCategoryBlockFragment on ProductCategoryBlock {
category {
id
products {
edges {
blocks {
__typename
...ProductCardBlockFragment
}
}
}
}
}
fragment ProductCardBlockFragment on ProductCardBlock {
__typename
...ProductCardTitleFragment
...ProductCardDescriptionFragment
...ProductCardImageFragment
...ProductCardPriceFragment
}
fragment ProductCardTitleFragment on ProductCardTitle {
title
}
fragment ProductCardDescriptionFragment on ProductCardDescription {
description
}
fragment ProductCardImageFragment on ProductCardImage {
imageUrl
}
fragment ProductCardPriceFragment on ProductCardPrice {
priceFormatted
}
Example query plan
QueryPlan {
Sequence {
Fetch(service: "experience") {
{
page(url: $url) {
__typename
... on ProductCategoryBlock {
category {
__typename
id
template {
id
}
}
}
}
}
},
Flatten(path: "[email protected]") {
Fetch(service: "data") {
{
... on ProductCategory {
__typename
id
template {
id
}
}
} =>
{
... on ProductCategory {
products {
edges {
__typename
node {
id
}
template {
id
}
}
}
}
}
},
},
Flatten(path: "[email protected].@") {
Fetch(service: "experience") {
{
... on ProductEdge {
__typename
node {
id
}
template {
id
}
}
} =>
{
... on ProductEdge {
blocks {
__typename
... on ProductCardTitle {
__typename
product {
__typename
id
}
}
... on ProductCardDescription {
__typename
product {
__typename
id
}
}
... on ProductCardImage {
__typename
product {
__typename
id
}
}
... on ProductCardPrice {
__typename
product {
__typename
id
}
}
}
}
}
},
},
Flatten(path: "[email protected][email protected][email protected]") {
Fetch(service: "data") {
{
... on Product {
__typename
id
}
} =>
{
... on Product {
name
description
imageUrl
price {
value
currency
}
}
}
},
},
Flatten(path: "[email protected][email protected].@") {
Fetch(service: "experience") {
{
... on ProductCardTitle {
__typename
product {
name
id
}
}
... on ProductCardDescription {
__typename
product {
description
id
}
}
... on ProductCardImage {
__typename
product {
imageUrl
id
}
}
... on ProductCardPrice {
__typename
product {
price {
value
currency
}
id
}
}
} =>
{
... on ProductCardTitle {
title
}
... on ProductCardDescription {
description
}
... on ProductCardImage {
imageUrl
}
... on ProductCardPrice {
priceFormatted
}
}
},
},
},
}
Prop drilling vs setContext
This feels conceptually similar to "prop drilling" in React components. React has a concept of "context", state that is local to a tree of components. A parent component sets the context, and a descendent component can retrieve the context without the intermediary components knowing anything about it.
A Federation version of "context" might solve this elegantly. A directive for setting the context when an entity is resolved, and another for injecting the context into descendent fields might look like:
# 1. the query planner adds the context fields to the selection set, like it does with keys
# 2. it then stores some internal state mapping the context object
# ({ template: { id: "template-1" } }) to the keyed entity
type ProductCategoryBlock
@key(fields: "category { id }")
@setContext(name: "template", from: "template { id }") {
category: ProductCategory!
template: Template! @inaccessible
}
# 3. When the query planner needs to resolve the `blocks` field, it knows which
# `ProductCategoryBlock` this entity descends from, and can inject the context
# object from the internal state
type ProductEdge @key(fields: "node { id }") {
node: Product!
blocks(
template: Template @fromContext(name: "template")
): [ProductCardBlock!]!
}
I tried hacking this together using coprocessors and it was much harder than it seemed. The particularly nasty part is that entities are deduplicated. If I have:
- One
ProductCategoryBlockwithtemplate-1, which contains products P1, P2, P3 - Another
ProductCategoryBlockwithtemplate-2, which contains product P2, P3, P4
The query planner flattens and batches the ProductEdge entities into a list of P1, P2, P3, P4. We've lost track of the association with the ProductCategoryBlocks, so we can't call ProductEdge.blocks with different template arguments. (This is avoided when overloading keys because the ProductEdge entities are unique by template.) The query planner must keep this mapping internally so that it can appropriately nest the edge objects within the block objects.
I bet there's another way to do this that we haven't thought of so I wanted to write this down and start the discussion!
Exploring other use cases for context — this one is interesting but I'm less sure about it.
query FeaturedBookDetails {
featuredBook {
title
author {
name
# I want a list of books that _does not_ include the featured book
otherBooks {
title
}
}
}
}
Proposed solution using "context"
# books subgraph
type Book @key(fields: "id") @setContext(name: "bookFilter", from: "bookFilter { notEqual }") {
id: ID!
title: String
bookFilter: BookFilter @inaccessible # { notEqual: [this.id] }
author: Author
}
type Author @key(fields: "id") {
id: ID!
}
# authors subgraph
type Author @key(fields: "id") {
id: ID!
# this argument is nullable. if called outside of a
# `Book -> author -> otherBooks` chain, it will just return the author's books.
# (if not "inaccessible" it could also just be public API)
otherBooks(filter: BookFilter @fromContext(name: "bookFilter")): BookConnection
}
The following alternatives are possible today, and they don't seem that bad. But they quickly become onerous if there are any intermediary layers necessitating more key stuffing or extraneous fields.
Key stuffing alternative solution
# books subgraph
type Book @key(fields: "id") {
id: ID!
title: String
author: Author
}
type Author @key(fields: "id relevantBook { id }") {
id: ID!
relevantBook: Book @inaccessible
}
# authors subgraph
type Author @key(fields: "id relevantBook { id }") {
id: ID!
relevantBook: Book
otherBooks: BookConnection
}
`@requires` alternative solution
# books subgraph
type Book @key(fields: "id") {
id: ID!
title: String
author: Author
}
type Author @key(fields: "id") {
id: ID!
relevantBook: Book @inaccessible
}
# authors subgraph
type Author @key(fields: "id") {
id: ID!
relevantBook: Book @external
otherBooks: BookConnection @requires(fields: "relevantBook { id }")
}
`@requires` alternative solution no. 2 (non-functional)
# books subgraph
type Book @key(fields: "id") {
id: ID!
title: String
author: Author
# this doesn't work when pagination is involved!
otherBooksByAuthor: BookConnection @requires(fields: "author { books { edges { nodes { id } } } }")
}
type Author @key(fields: "id") {
id: ID!
books: BookConnection @external
}
# authors subgraph
type Author @key(fields: "id") {
id: ID!
books: BookBookConnection
}
+1
Experiencing a similar issue with a customer and their experience graph proof of concept.
The use-case involves presentational context being provided by the client determination of the constraints on the device and platform (100s of different varieties), which lines similarly to the use-case mentioned in your second post (homepage(client: clientContext) {}). However in federation world, key stuffing is leaking presentational boundaries to other domains schemas and requires this pattern to be replicated across further nested entities. Thus, we've settled on header context propagation, and will explore a Rhai script or body extension approach driven by the experience subgraph.
Let's also consider the idea of the @export directive which could help with some of these issues. https://github.com/graphql/graphql-spec/issues/377
I wanted to comment that a simpler option, while not declarative and not following any spec (and terrible to manage), is to use response extensions on subgraph responses and have a Router script/coprocessor to forward the extensions.context object to all subgraph requests
@smyrick Part of the goal is for the context to be limited to a specific branch of resolving data cross subgraph as not all branches are guaranteed to need the same context (otherwise headers could just be used). I think that could be achieved with what you’re describing, but it would be a nightmare to manage. That being said, custom directives could probably be developed to make this extensions idea more declarative and simpler to manage at the subgraph level.
I do think this is a fairly large gap that federation can and should solve.
@gwardwell Totally agree, this would be a perfect fit for the query planner. I wanted to just add some possible workarounds for those "blocked" today
This feature request is resolved by the @context / @fromContext directives released in Federation 2.8.