Advanced topics on federated entities
This article describes complex behaviors of federated entities beyond those covered in entity basics.
Using advanced @key
s
Depending on your entities' fields and usage, you may need to use more advanced @key
s. For example, you may need to define a compound @key
if multiple fields are required to uniquely identify an entity. If different subgraphs interact with different fields an entity, you may need to define multiple—and sometimes differing—@key
s for the entity.
Compound @key
s
A single @key
can consist of multiple fields, the combination of which uniquely identifies an entity. This is called a compound or composite key. In the following example, the combination of both username
and domain
fields is required to uniquely identify the User
entity:
type User @key(fields: "username domain") {username: String!domain: String!}
Nested fields in compound @key
s
Compound keys can also include nested fields. In the following example, the User
entity's @key
consists of both a user's id
and the id
of that user's associated Organization
:
type User @key(fields: "id organization { id }") {id: ID!organization: Organization!}type Organization {id: ID!}
Multiple @key
s
When different subgraphs interact with different fields of an entity, you may need to define multiple @key
s for the entity. For example, a Reviews subgraph might refer to products by their ID, whereas an Inventory subgraph might use SKUs.
In the following example, the Product
entity can be uniquely identified by either its id
or its sku
:
type Product @key(fields: "id") @key(fields: "sku") {id: ID!sku: String!name: String!price: Int}
Note: If you include multiple sets of @key
fields, the query planner uses the most efficient set for entity resolution. For example, suppose you allow a type to be identified by @key(fields: "id")
or @key(fields: "id sku")
:
type Product @key(fields: "id") @key(fields: "id sku") {# ...}
That means either id
or (id
and sku
) is enough to uniquely identify the entity. Since id
alone is enough, the query planner will use only that field to resolve the entity, and @key(fields: "id sku")
is effectively ignored.
Referencing entities with multiple keys
A subgraph that references an entity without contributing any fields can use any @key
fields in its stub definition. For example, if the Products subgraph defines the Product
entity like this:
type Product @key(fields: "id") @key(fields: "sku") {id: ID!sku: String!name: String!price: Int}
Then, a Reviews subgraph can use either id
or sku
in the stub definition:
# Either:type Product @key(fields: "id", resolvable: false) {id: ID!}# Or:type Product @key(fields: "sku", resolvable: false) {sku: String!}
When resolving a reference for an entity with multiple keys, you can determine how to resolve it based on which key is present. For example, if you're using @apollo/subgraph
, it could look like this:
// Products subgraphconst resolvers = {Product: {__resolveReference(productRepresentation) {if(productRepresentation.sku){return fetchProductBySku(productRepresentation.sku);} else {return fetchProductByID(productRepresentation.id);}}},// ...other resolvers...}
Differing @key
s across subgraphs
Although an entity commonly uses the exact same @key
field(s) across subgraphs, you can alternatively use different @key
s with different fields. For example, you can define a Product
entity shared between subgraphs, one with sku
and upc
as its @key
s, and the other with only upc
as the @key
field:
type Product @key(fields: "sku") @key(fields: "upc") {sku: ID!upc: String!name: String!price: Int}
type Product @key(fields: "upc") {upc: String!inStock: Boolean!}
To merge entities between subgraphs, the entity must have at least one shared field between subgraphs. For example, operations can't merge the Product
entity defined in the following subgraphs because they don't share any fields specified in the @key
selection set:
❌
type Product @key(fields: "sku") {sku: ID!name: String!price: Int}
type Product @key(fields: "upc") {upc: String!inStock: Boolean!}
Operations with differing @key
s
Differing keys across subgraphs affect which of the entity's fields can be resolved from each subgraph. Requests can resolve fields if there is a traversable path from the root query to the fields.
Take these subgraph schemas as an example:
type Product @key(fields: "sku") {sku: ID!upc: String!name: String!price: Int}type Query {product(sku: ID!): Productproducts: [Product!]!}
type Product @key(fields: "upc") {upc: String!inStock: Boolean!}
The queries defined in the Products subgraph can always resolve all product fields because the product entity can be joined via the upc
field present in both schemas.
On the other hand, queries added to the Inventory subgraph can't resolve fields from the Products subgraph:
type Product @key(fields: "sku") {sku: ID!upc: String!name: String!price: Int}
type Product @key(fields: "upc") {upc: String!inStock: Boolean!}type Query {productsInStock: [Product!]!}
The productsInStock
query can't resolve fields from the Products subgraph since the Products subgraph's Product
type definition doesn't include upc
as a key field, and sku
isn't present in the Inventory subgraph.
If the Products subgraph includes @key(fields: "upc")
, all queries from the Inventory subgraph can resolve all product fields:
type Product @key(fields: "sku") @key(fields: "upc") {sku: ID!upc: String!name: String!price: Int}
type Product @key(fields: "upc") {upc: String!inStock: Boolean!}type Query {productsInStock: [Product!]!}
Migrating entities and fields
As your supergraph grows, you might want to move parts of an entity to a different subgraph. This section describes how to perform these migrations safely.
Incremental migration with @override
Let's say our Payments subgraph defines a Bill
entity:
type Bill @key(fields: "id") {id: ID!amount: Int!payment: Payment}type Payment {# ...}
Then, we add a dedicated Billing subgraph to our supergraph. It now makes sense to move billing functionality there. When we're done migrating, we want our deployed subgraph schemas to look like this:
type Bill @key(fields: "id") {id: ID!payment: Payment}type Payment {# ...}
type Bill @key(fields: "id") {id: ID!amount: Int!}
The @override
directive enables us to perform this migration incrementally with no downtime.
First, we deploy a new version of the Billing subgraph that defines and resolves the Bill
fields we want to move:
type Bill @key(fields: "id") {id: ID!amount: Int!payment: Payment}type Payment {# ...}
type Bill @key(fields: "id") {id: ID!amount: Int! @override(from: "Payments")}
The @override
directive says, "Resolve this field in this subgraph instead of in the Payments subgraph."
In any subgraph where you use @override
, make sure to include it in your schema's @link
imports (code-first subgraph libraries usually do this for you):
extend schema@link(url: "https://specs.apollo.dev/federation/v2.3",import: ["@key", "@shareable", "@override"])
Next, we update our router's supergraph schema to include the updated Billing subgraph. If you're using managed federation, you do this by publishing the Billing subgraph's schema to GraphOS with rover subgraph publish
.
When the router receives its updated supergraph schema, it immediately starts resolving the Bill.amount
field from the Billing subgraph while continuing to resolve Bill.payment
from the Payments subgraph.
We can migrate as many entity fields as we want in a single change. To do so, we apply @override
to every entity field we want to move. We can even migrate entire entities this way!
Now that Bill.amount
is resolved in the Billing subgraph, we can safely remove that field (and its resolver) from the Payments subgraph:
type Bill @key(fields: "id") {id: ID!payment: Payment}type Payment {# ...}
type Bill @key(fields: "id") {id: ID!amount: Int! @override(from: "Payments")}
After making this change, we deploy our updated Payments subgraph and again update our router's supergraph schema.
Because the router is already ignoring Bill.amount
in the Payments subgraph thanks to @override
, we can safely publish our updated schema and deploy the subgraph in any order!
Finally, we can remove the @override
directive from the Billing subgraph, because it no longer has any effect:
type Bill @key(fields: "id") {id: ID!payment: Payment}type Payment {# ...}
type Bill @key(fields: "id") {id: ID!amount: Int!}
After we deploy the Billing subgraph and publish this final schema change, we're done! We've migrated Bill.amount
to the Billing subgraph with zero downtime.
Optimizing for fewer deploys with manual composition
⚠️ This method requires careful coordination between subgraph and router updates. Without strict control over the order of deployments and schema updates, you might cause an outage. For most use cases, we recommend using the @override
method above.
Using @override
to migrate entity fields enables us to migrate fields incrementally with zero downtime. However, doing so requires three separate schema publishes. If you're using manual composition, each schema change requires redeploying your router. With careful coordination, we can perform the same migration with only a single router redeploy.
In the Billing subgraph, define the
Bill
entity, along with its corresponding resolvers. These new resolvers should behave identically to the Payment subgraph resolvers they're replacing.Payments subgraphtype Bill @key(fields: "id") {id: ID!amount: Int!payment: Payment}type Payment {# ...}Billing subgraphtype Bill @key(fields: "id") {id: ID!amount: Int!}Deploy the updated Billing subgraph to your environment, but do not publish the updated schema yet.
- At this point, the Billing subgraph can successfully resolve
Bill
objects, but the router doesn't know this yet because its supergraph schema hasn't been updated. Publishing the schema would cause a composition error.
- At this point, the Billing subgraph can successfully resolve
In the Payments subgraph, remove the migrated fields from the
Bill
entity and their associated resolvers (do not deploy this change yet):Payments subgraphtype Bill @key(fields: "id") {id: ID!payment: Payment}type Payment {# ...}Billing subgraphtype Bill @key(fields: "id") {id: ID!amount: Int!}Compose an updated supergraph schema with your usual configuration using
rover supergraph compose
.- This updated supergraph schema indicates that the Billing subgraph resolves
Bill.amount
, and the Payments subgraph doesn't.
- This updated supergraph schema indicates that the Billing subgraph resolves
Assuming CI completes successfully, deploy an updated version of your router with the new supergraph schema.
- When this deployment completes, the router begins resolving
Bill
fields in the Billing subgraph instead of the Payments subgraph.
⚠️ While your new router instances are deploying, you will probably have active router instances resolving the
Bill.amount
field in two different ways (with older instances still resolving it from Payments). It's important that the two subgraphs resolve the field in exactly the same way, or your clients might see inconsistent data during this rollover.- When this deployment completes, the router begins resolving
Deploy the updated version of your Payments subgraph without the migrated field.
- At this point it's safe to remove this definition, because your router instances are using the Billing subgraph exclusively.
We're done! The migrated fields have been moved to a new subgraph, and we only redeployed our router once.
Contributing computed entity fields
You can define fields of an entity that are computed based on the values of other entity fields that are resolved by a different subgraph.
For example, this Shipping subgraph adds a shippingEstimate
field to the Product
entity. This field is calculated based on the product's size
and weight
, which are defined in the Products subgraph:
type Product @key(fields: "id") {id: ID!size: Int @externalweight: Int @externalshippingEstimate: String @requires(fields: "size weight")}
As shown, you use the @requires
directive to indicate which fields (and subfields) from other subgraphs are required. You also need to define the required fields and apply the @external
directive to them. This directive tells the router, "This subgraph knows that these fields exist, but it can't resolve them itself."
In the above example, if a query requests a product's shippingEstimate
, the router does the following, in order:
- It queries the Products subgraph for the product's
size
andweight
. - It queries the Shipping subgraph for the product's
shippingEstimate
. Thesize
andweight
are included in theProduct
object passed to the resolver forshippingEstimate
:
{Product: {shippingEstimate(product) {return computeShippingEstimate(product.id, product.size, product.weight);}}}
Using @requires
with object subfields
If a computed field @requires
a field that returns an object type, you also specify which subfields of that object are required. You list those subfields with the following syntax:
type Product @key(fields: "id") {id: ID!dimensions: ProductDimensions @externalshippingEstimate: String @requires(fields: "dimensions { size weight }")}
In this modification of the previous example, size
and weight
are now subfields of a ProductDimensions
object. Note that the ProductDimensions
type must be defined in both the Products and Shipping subgraphs for this to be valid.
Using @requires
with fields that take arguments
This functionality was introduced in Federation v2.1.2
.
The @requires
directive can include fields that take arguments, like so:
type Product @key(fields: "id") {id: ID!weight(units: String): Int @externalshippingEstimate: String @requires(fields: "weight(units:\"KILOGRAMS\")")}
- The router provides the specified values in its query to whichever subgraph defines the required field.
- Each specified argument value is static (i.e., the router always provides the same value).
- You can omit values for nullable arguments. You must provide values for non-nullable arguments.
- If you define your subgraph schema in an SDL file (instead of programmatically), you must escape quotes for string and enum values with backslashes (as shown above).
Resolving another subgraph's field
By default, exactly one subgraph is responsible for resolving each field in your supergraph schema (with important exceptions, like entity @key
fields). But sometimes, multiple subgraphs are able to resolve a particular entity field, because all of those subgraphs have access to a particular data store. For example, an Inventory subgraph and a Products subgraph might both have access to the database that stores all product-related data.
You can enable multiple subgraphs to resolve a particular entity field. This is a completely optional optimization. When the router plans a query's execution, it looks at which fields are available from each subgraph. It can then attempt to optimize performance by executing the query across the fewest subgraphs needed to access all required fields.
You achieve this with one of the following directives:
Which directive you use depends on the following logic:
If you aren't sure whether your subgraph can always resolve a field, see Using @provides
for an example of a subgraph that can't.
Ensure resolver consistency
If multiple subgraphs can resolve a field, make sure each subgraph's resolver for that field behaves identically. Otherwise, queries might return inconsistent results to clients depending on which subgraph resolves the field.
This is especially important to keep in mind when making changes to an existing resolver. If you don't make the resolver changes to each subgraph simultaneously, clients might observe inconsistent results.
Common inconsistent resolver behaviors to look out for include:
- Returning a different default value
- Throwing different errors in the same scenario
Using @shareable
⚠️ Before using @shareable
, see Ensure resolver consistency.
The @shareable
directive indicates that a particular field can be resolved by more than one subgraph:
type Product @key(fields: "id") {id: ID!name: String! @shareableprice: Int}
type Product @key(fields: "id") {id: ID!name: String! @shareableinStock: Boolean!}
In this example, both the Products and Inventory subgraphs can resolve Product.name
. This means that a query that includes Product.name
might be resolvable by fetching from fewer total subgraphs.
If a field is marked @shareable
in any subgraph, it must be marked @shareable
or @external
in every subgraph that defines it. Otherwise, composition fails.
Using @provides
⚠️ Before using @provides
, see Ensure resolver consistency.
The @provides
directive indicates that a particular field can be resolved by a subgraph at a particular query path. Let's look at an example.
Here, our Products subgraph defines a Product.name
field and marks it @shareable
(this means other subgraphs are allowed to resolve it):
type Product @key(fields: "id") {id: ID!name: String! @shareableprice: Int}
Meanwhile, our Inventory subgraph can also resolve a product's name, but only when that product is part of an InStockCount
:
type InStockCount {product: Product! @provides(fields: "name")quantity: Int!}type Product @key(fields: "id") {id: ID!name: String! @externalinStock: Boolean!}
Here we're using two directives in combination: @provides
and @external
.
- The
@provides
directive tells the router, "This subgraph can resolve thename
of anyProduct
object returned byInStockCount.product
." - The
@external
directive tells the router, "This subgraph can't resolve thename
of aProduct
object, except wherever indicated by@provides
."
Rules for using @provides
- If a subgraph
@provides
a field that it can't always resolve, the subgraph must mark that field as@external
and must not mark it as@shareable
.- Remember, a
@shareable
field can always be resolved by a particular subgraph, which removes the need for@provides
.
- Remember, a
- To include a field in a
@provides
directive, that field must be marked as@shareable
or@external
in every subgraph that defines it.
Violating any of these rules causes composition to fail.
Handling the N+1 problem
Most subgraph implementations use reference resolvers (sometimes known as entity resolvers) to handle the Query._entities
field ergonomically. A reference resolver is passed a single key and returns the entity object that corresponds to that key.
Although this pattern is straightforward, it can diminish performance when a client operation requests fields from many entities. To illustrate this, let's revisit an earlier example:
query GetReviewsWithProducts {latestReviews { # Defined in Reviewsscoreproduct {idprice # ⚠️ NOT defined in Reviews!}}}
As mentioned in The query plan, the router executes two queries on its subgraphs to resolve the above operation:
- It queries the Reviews subgraph to fetch all fields except
Product.price
. - It queries the Products subgraph to fetch the
price
of eachProduct
entity.
In the Products subgraph, the reference resolver for Product
doesn't take a list of keys, but rather a single key. Therefore, the subgraph library calls the reference resolver once for each key:
// Products subgraphconst resolvers = {Product: {__resolveReference(productRepresentation) {return fetchProductByID(productRepresentation.id);}},// ...other resolvers...}
A basic implementation of the fetchProductByID
function might make a database call each time it's called. If we need to resolve Product.price
for N
different products, this results in N
database calls. These calls are made in addition to the call made by the Reviews subgraph to fetch the initial list of reviews (and the id
of each product). This is where the "N+1" problem gets its name. If not prevented, this problem can cause performance problems or even enable denial-of-service attacks.
This problem is not limited to reference resolvers! In fact, it can occur with any resolver that fetches from a data store. To handle this problem, we strongly recommend using the dataloader pattern. Nearly every GraphQL server library provides a dataloader implementation, and you should use it in every resolver. This is true even for resolvers that aren't for entities and that don't return a list. These resolvers can still cause N+1 issues via batched requests.