Extend the GraphQL schema
When developing an e-commerce store, you might at some point need to expose new data in your unified GraphQL schema to support new features and allow frontend developers to use them.
The concepts documented below have been adopted by many actors in the GraphQL ecosystem since our initial implementation years ago. On our road to 1.0.0, we have opened a RFC in #44 to discuss using an external library sharing the same principles instead of maintaining our own implementation. The migration path would be seamless if we chose to migrate to this library. Please do not hesitate to share your thoughts with us there.
Front-Commerce’s GraphQL modules is the mechanism allowing to extend and override any part of the schema defined by other modules. It leverages features from the GraphQL Schema Definition Language (SDL).
Front-Commerce’s core and platforms integrations (such as Magento2) are implemented as GraphQL modules too.
This page will guide you through the process of exposing a new feature in your GraphQL schema. We will create a GraphQL module that allows to maintain a counter of clicks for a product.
You will learn to:
- create a new GraphQL module
- register the GraphQL module in the Front-Commerce application
- implement the GraphQL module itself: add a field to the
Product
type and add a newMutation
to the schema
Create a new GraphQL module
Let’s say that we want to expose a counter of clicks for each product in our
store. We first have to create a ClicksCounters
GraphQL module.
This GraphQL module should appear in the folder of a Front-Commerce module. For
instance, if the Front-Commerce module is in my-module
, the GraphQL module
should be in my-module/server/modules/clicks-counter
. Learn more about
Front-Commerce modules within
Extend the theme.
In the GraphQL module folder, there should be a definition file
my-module/server/modules/clicks-counters/index.js
which will set how the
module should behave within your Schema:
// my-module/server/modules/clicks-counters/index.js
export default {
namespace: "ClicksCounters"
};
A GraphQL module has to export an object containing the different services it provides.
This module is valid even though it does nothing. However, it is not used in your application yet. You need to register it.
Register the module in the application
Front-Commerce allows you to manage your modules in the serverModules
key of
the .front-commerce.js
file located in your project’s root.
Let’s add a ClicksCounters
GraphQL module by adding it to the existing list:
// .front-commerce.js
module.exports = {
name: "Front-Commerce",
url: "https://www.front-commerce.test",
modules: [],
serverModules: [
{ name: "FrontCommerceCore", path: "server/modules/front-commerce-core" },
- { name: "Magento2", path: "server/modules/magento2" }
+ { name: "Magento2", path: "server/modules/magento2" },
+ { name: "ClicksCounters", path: "./my-module/server/modules/clicks-counters" }
]
};
The name
key must be unique across your server modules. It is a temporary name
that is used during a code generation step and has no other usage in the
application. Do not worry about it, it will be deprecated in a near future: see
#179 for further
information.
The path
must be a path to your module definition file (created above).
In depth: under the hood, Front-Commerce is generating a.front-commerce/modules.js
file from this configuration which is loaded very early in the server bootstrapping process. The name is used to import the correct module and export it in an homogeneous list, used by thewithGraphQLApi
express middleware.
Restart your application.
Congratulations! You now have a new custom module ready to enhance the data exposed in the GraphQL middleware.
Implement module’s features
All the wiring is now done and it is time to develop the features of this module. In GraphQL a good practice is to start thinking about the schema, from a business domain perspective.
It is important to name things with a language shared by the team and prevent exposing implementation details (ids, different names…) as much as possible. We recommend the reading of the GraphQL documentation page Thinking in Graphs.
Let’s add the code to expose a counter field and a mutation in our graph.
Define the schema
Front-Commerce lets you describe your schema using the expressive GraphQL Schema Definition Language (SDL).
Create a my-module/server/modules/clicks-counters/schema.gql
file to contain
the schema matching our use case.
We would like to:
- add a
clicksCounter
field to the existingProduct
type - add an
incrementProductCounter
mutation
It could look like so:
# my-module/server/modules/clicks-counters/schema.gql
extend type Product {
clicksCounter: Int
}
extend type Mutation {
incrementProductCounter(sku: String!, incrementValue: Int): MutationSuccess
}
TheProduct
type is part of Front-Commerce’sMagento2
module. TheMutationSuccess
type used as the mutation response type is part of Front-Commerce’s core.
Please note that it could have been implemented as a top level Query as well:However we feel it would have been less intuitive for frontend developers. It may seem more natural for people used to design relational databases or REST APIs, but we recommend to try limiting as much as possible the number of top-level queries in your projects to learn thinking in GraphQL.extend type Query { clicksCounterByProductSKU(sku: String!): Int }
Then expose the schema using the typeDefs
key of the module definition. We
recommend to make the dependency to Magento’s Product module explicit by adding
a dependencies
key. Update the
my-module/server/modules/clicks-counters/index.js
entrypoint:
// my-module/server/modules/clicks-counters/index.js
+import typeDefs from "./schema.gql";
+
export default {
- namespace: "ClicksCounters"
+ namespace: "ClicksCounters",
+ dependencies: [
+ "Magento2/Catalog/Products"
+ ],
+ typeDefs: typeDefs
};
Webpack is in fact taking care of converting SDL into a Javascript object using an appropriate loader.
Congratulations again! You should now be able to see these new fields and use them in GraphQL, without even having to restart the application!
Try to execute the query below in your GraphQL playground (by default at http://localhost:4000/playground):
# http://localhost:4000/playground
{
product(sku: "WH09") {
sku
clicksCounter
}
}
Our GraphQL module does not yet have any code for sending content. This means
that even if you can request the clicksCounter
field, it won’t actually return
any data. So you should see the following response:
{
"data": {
"product": {
"sku": "WH09",
"clicksCounter": null
}
}
}
Let’s now add some executable logic to our module.
Implement resolvers
The GraphQL middleware relies on resolver functions to determine the data to return for a given field. This is where most of the « real work » is done, for instance by fetching remote datasources or transforming data.
Resolvers are exposed using the resolvers
key of the module definition. It
should be a
Resolver map:
an object where each key is a GraphQL type name, and values are mapping between
field names and resolver function. Resolver functions may return a
Promise
for asynchronous operations.
To know more about resolvers and their internals, we recommend the reading of GraphQL Tools resolvers documentation.
First, update the module definition as follow:
// my-module/server/modules/clicks-counters/index.js
import typeDefs from "./schema.gql";
+import resolvers from "./resolvers";
export default {
namespace: "ClicksCounters",
dependencies: [
"Magento2/Catalog/Products"
],
- typeDefs: typeDefs
+ typeDefs: typeDefs,
+ resolvers: resolvers
};
And then create the my-module/server/modules/clicks-counters/resolvers.js
file
with a resolver map as below (we are going to analyze it in details in a few
seconds):
// my-module/server/modules/clicks-counters/resolvers.js
const counters = new Map();
const currentValueOf = sku => counters.get(sku) || 0;
module.exports = {
Product: {
clicksCounter: ({ sku }) => currentValueOf(sku)
},
Mutation: {
incrementProductCounter(_, { sku, incrementValue = 1 }) {
counters.set(sku, currentValueOf(sku) + incrementValue);
return {
success: true
};
}
}
};
Let’s analyze this code in detail.
Returning the counter value for every Product
First of all, the exported resolver map defines a resolver for the
clicksCounter
field of any Product
field.
// …
module.exports = {
Product: {
clicksCounter: ({ sku }) => currentValueOf(sku)
}
// …
};
It implements data retrieval for the following part of the schema declared earlier:
extend type Product {
clicksCounter: Int
}
The resolver function will use the sku
key from its parent data and will
return the the current counter value from its local state (defaulting to 0
if
no clicks occured).
It is important to understand that the sku
is not a parameter provided by
frontend developers in the Query. It comes from data returned by earlier
resolvers that fetched a Product
, no matter where in the graph (category,
upsells, cart…).
Incrementing the counter for a SKU
The second part of this resolver map is a incrementProductCounter
mutation.
Mutations in GraphQL are a way to modify server-side data, like POST
requests
in REST (whereas queries allow to read data, like GET
requests in REST).
GraphQL modules must declare mutations by extending the top-level GraphQL
Mutation
type. You can read more about Mutations in the
GraphQL’s Mutations documentation.
// …
module.exports = {
// …
Mutation: {
incrementProductCounter(_, { sku, incrementValue = 1 }) {
counters.set(sku, currentValueOf(sku) + incrementValue);
return {
success: true
};
}
}
};
In this resolver, the first parameter is unused because in a top level resolver
there is no parent data. The second parameter contains arguments passed to the
mutation. These arguments are the (sku: String!, incrementValue: Int)
part of
the schema definition declared earlier:
extend type Mutation {
incrementProductCounter(sku: String!, incrementValue: Int): MutationSuccess
}
The resolver will:
- use the mandatory
sku
argument to fetch the current counter value from its local state - increment the counter value of the
incrementValue
optional argument. If theincrementValue
argument has not been defined explicitely in the GraphQL request, it defaults to1
. - set this new incremented value for the counter
- finally, return a value matching the
MutationSuccess
Front-Commerce type which in this example is always successful ({success: true}
).
ProTip™: you can debug the data passed in a resolver usingconsole.log
to view its shape in your server console shell output. To do so, a convenient thing to know is thatconsole.log
returns a falsy value. Hence, in the resolver above you could debug thesku
value by updating the resolver as below:-clicksCounter: ({sku}) => currentValueOf(sku), +clicksCounter: ({sku}) => console.log(sku) || currentValueOf(sku),
Awesome! Your GraphQL server now has a new feature, let’s use it!
Query your new graph
Try to execute the previous query again in your GraphQL playground:
# http://localhost:4000/playground
{
product(sku: "WH09") {
sku
clicksCounter
}
}
You must now have a real value returned!
{
"data": {
"product": {
"sku": "WH09",
"clicksCounter": 0
}
}
}
You can even increment the counter by sending a Mutation to your server:
# http://localhost:4000/playground
mutation {
incrementProductCounter(sku: "WH09", incrementValue: 2) {
success
}
}
If it was successful, the above query must now return 2
in the clicksCounter
field.
Asynchronous resolvers
In the previous example resolvers were synchronous, meaning that they returned a
value that was immediately available. Most of the time, your resolvers will
fetch data from a remote service and return them asynchronously using a
Promise
.
Returning Promise
from resolvers is supported out of the box. Here is an
example of turning the previous resolvers to asynchronous ones:
// my-module/server/modules/clicks-counters/resolvers.js
const counters = new Map();
const currentValueOf = sku => {
return new Promise(resolve => {
setTimeout(() => {
const currentValue = counters.get(sku) || 0;
resolve(currentValue);
}, Math.random() * 3000);
});
};
module.exports = {
Product: {
clicksCounter: ({ sku }) => currentValueOf(sku)
},
Mutation: {
incrementProductCounter(_, { sku, incrementValue = 1 }) {
return currentValueOf(sku)
.then(currentValue => counters.set(sku, currentValue + incrementValue))
.then(() => ({
success: true
}));
}
}
};
Make it scale!
Over time your resolvers may grow and contain more business logic. See Slim down resolvers with loaders to learn how you could extract logic from your resolvers and keep them easy to understand.