Core concepts
How schema composition works
Apollo Federation works through a declarative composition model where services expose their capabilities and together they can be formed into a single graph. This section describes the core concepts in the programming model.
Entities and keys
An entity is a type that can be referenced by another service. Entities create connection points between services and form the basic building blocks of a federated graph. Entities have a primary key whose value uniquely identifies a specific instance of the type, similar to the function of a primary key in a SQL table.
Declaring an entity is done by adding a @key
directive to the type definition. The directive takes one argument specifying the key:
type Product @key(fields: "upc") { upc: String!
name: String!
price: Int
}
In this example, the @key
directive tells the Apollo query planner that a particular instance of Product
can be fetched if you have its upc
. Unlike Relay's Node interface, keys can be any field (not just ID
) and need not be globally unique. The ability to specify an entity's key makes it easier to build a data graph on top of existing APIs and services that already have a notion of a primary key, and encourages a more natural product-centric type definition.
Apollo supports multiple keys for an entity and composite keys (combination of fields). See advanced features for more information on these options.
Referencing external types
Once an entity is part of the graph, other services can begin to reference that type from their own types. Let's look at how the reviews service can join across services to return a Product
:
# in the reviews service
type Review {
product: Product
}
extend type Product @key(fields: "upc") {
upc: String! @external
}
In this example we have a Review
type with a field called product
that returns the Product
type. Since Product
is an entity that lives in another service, we define a stub of that type in this service with just enough information to enable composition. The syntax may look a bit strange at first, so let's unpack it:
- The
extend
keyword declares thatProduct
is an entity defined elsewhere, in this case the product catalog service. - The
@key
directive declares that we'll use a UPC to reference a particular product. This must match the referenced entity's own key as defined in the product catalog service. - The definition of the
upc
field with an@external
directive declares the type of theupc
field (String!
, in this case) that is implemented in another service.
This explicit syntax has several benefits. It is standard GraphQL grammar. It allows us to run the reviews service standalone with a valid schema, including a Product
type with a single upc
field. And it provides strong typing information that lets us catch mistakes at schema composition time.
With the type definitions in place, we can write a resolver for Review.product
. Instead of returning a complete Product
object (we can't; this service doesn't know much about products), the resolver just returns a reference to the external type.
{
Review: {
product(review) {
return { __typename: "Product", upc: review.product_upc };
}
}
}
The { __typename: "Product", upc: review.product_upc }
object is a representation of a Product
entity. Representations are how services reference each others' types. They contain an explicit typename definition and a value for the key.
The gateway will use the representation as an input to the service that owns the referenced entity. So to allow the gateway to enter the graph in this manner and resume execution of the query, the last thing we need is a reference resolver back in the product catalog service. We only write this once per entity.
{
Product: {
__resolveReference(reference) { return fetchProductByUPC(reference.upc); } }
}
Reference resolvers are a special addition to Apollo Server that allow individual types to be resolved by a reference from another service. They are called when a query references an
entity
across service boundaries. To learn more about__resolveReference
, see the API docs.
What is nice about this model is that we end up with a schema that represents a true subset of the overall graph, as opposed to a mangled schema with foreign key fields like productID
. Ultimately, this means clients can write queries like this without having to ask for special fields or make additional requests to other services.
{
reviews {
product {
name
price
}
}
}
So to review: to reference an external entity from a resolver, we
- Define a stub type for the entity we want to reference. The key as declared in the stub must match the entity's own declaration.
- From the referencing resolver, return a representation.
- In the referenced service, implement a reference resolver for the entity.
Extending external types
Returning a reference to an author represents just one side of a relationship. A true data graph should expose the ability to navigate relationships in both directions. You'll want to be able to go from a product to its reviews, for example. While these fields are exposed on Product
for the client to query, they can't be part of the accounts service because they are a concern of the reviews service.
The extend type
mechanism is all we need for this use case. While the Product
type belongs to the product catalog service, other services can define extension fields on this type using the extend type
syntax.
The query planner will make sure the fields required by a resolver on an extension field are requested from the service hosting the type even if the user didn't request them directly. Every resolver that is added to a type from another service will receive the fields requested in the @key
directive on the type extension.
For example, if we wanted to add a reviews field to the Product
type:
extend type Product @key(fields: "upc") {
upc: String! @external
reviews: [Review]}
Since the reviews service already had a concept of the Product
type from returning it, adding additional fields to the overall type can be done just like it was a normal type.
The generated query plan will fetch the upc
field for each Product
from the product catalog service and pass those to the reviews service, where you can then access these fields on the object passed into your reviews
resolver:
{
Product: {
reviews(product) {
return fetchReviewsForProduct(product.upc);
}
}
}
Type extensions aren't just useful for relationships. You can also use them to extend types with additional scalar or other value fields. Here, we want to be able to query for the inStock
status of a product. That information lives in an inventory service, so we'll add the type extension there:
extend type Product @key(fields: "upc") {
upc: ID! @external
inStock: Boolean}
{
Product: {
inStock(product): {
return fetchInStockStatusForProduct(product.upc);
}
}
}
Similar to the reviews
relationship example above, the gateway will fetch the required upc
field from the product catalog service and pass it to the inventory service, even if the query didn't ask for the upc
:
query {
topProducts {
inStock
}
}
Root queries and mutations
Since Query
and Mutation
are regular types in GraphQL, we use the same extend type
pattern to define root queries. This gives us a mechanism for how each service can define root queries for the composed graph.
To implement a root query, such as topProducts
, we simply extend the Query
type:
extend type Query {
topProducts(first: Int = 5): [Product]
}
There is no need to explicitly define Query
or Mutation
base types anywhere; Apollo automatically handles this for you.
Value Types
A natural overlap among identical types between services is not uncommon. Rather than having a single service "own" those types, all services that use them are expected to share ownership. This form of type "duplication" across services is supported for Scalars, Objects, Interfaces, Enums, Unions, and Inputs. The rule of thumb for any of these value types is that the types must be identical in name and contents.
Objects, Interfaces, and Inputs
For types with field definitions, all fields and their types must be identical.
Scalars
For Scalar values, it's important that services share the same serialization and parsing logic, since there is no way to validate that logic from the schema level by federation tooling.
Enums
For Enum types, all values must match across services. Even if a service doesn't use all values in an Enum, they still must be defined in the schema. Failure to include all enum values in all services that use the Enum will result in a validation error when building the federated schema.
Unions
Union types must share the same types in the union, even if not all types are used by a service.
In the following example, the Product and User services both use the same ProductCategory
enum, Date
scalar, Error
type, and ProductOrError
union.
# Product Service
scalar Date
union ProductOrError = Product | Error
type Error {
code: Int!
message: String!
}
type Product @key(fields: "sku"){
sku: ID!
category: ProductCategory
dateCreated: Date
}
enum ProductCategory {
FURNITURE
BOOK
DIGITAL_DOWNLOAD
}
# User Service
scalar Date
union ProductOrError = Product | Error
type Error {
code: Int!
message: String!
}
type User @key(fields: "id"){
id: ID!
dateCreated: Date
favoriteCategory: ProductCategory
favoriteProducts: [Product!]
}
enum ProductCategory {
FURNITURE
BOOK
DIGITAL_DOWNLOAD
}
extend type Product @key(fields: "sku"){
sku: ID! @external
}