Written by Leonardo Losoviz✏️
This article is part of an ongoing series on conceptualizing, designing, and implementing a GraphQL server. The previous articles from the series are:
- Designing a GraphQL server for optimal performance
- Simplifying the GraphQL data model
- Schema-first vs code-first development in GraphQL
Principled GraphQL is a set of best practices for creating, maintaining, and operating a data graph. Created by the Apollo team in response to feedback from thousands of developers, it puts forward a standard of best practices for creating a GraphQL service. Following this set of principles from the beginning of your project can be very helpful, especially if you’re just starting with GraphQL.
Of the 10 best practices that comprise Principled GraphQL, we’ll focus on the three from the Integrity Principles section, which defines how a graph should be created, managed, and exposed.
- One Graph: Your company should have one unified graph, instead of multiple graphs created by each team
- Federated Implementation: Though there is only one graph, the implementation of that graph should be federated across multiple teams
- Track the Schema in a Registry: There should be a single source of truth for registering and tracking the graph
The first two principles establish that the graph is a coordinated effort involving people from different teams, since creating a GraphQL service requires making not only technical decisions but logistical ones as well, like how to set up a companywide process that enables everyone to contribute to the same graph.
Let’s say that the sales team owns, manages, and develops the Product
type, which currently has the following schema.
type Product {
id: Int
name: String!
price: Int!
}
Now, a different team — let’s say, the tutorials team — decides to launch its tutorials at a discounted price. The team wants to add a field called discountedPrice
on the Product
type (since a tutorial, in this case, is a product). There are a few methods by which the team can achieve this:
-
Autonomously — since each team owns its services, the tutorials team can create a
TutorialProduct
type that extends from theProduct
type and add the field under this type -
Delegation — the tutorials team can ask the sales team to add the field to the
Product
type -
Cross-boundary — the tutorials team can directly add the field to the
Product
type
Principled GraphQL weighs in on this under the second principle, Federated Implementation, which states that “each team should be responsible for maintaining the portion of the schema that exposes their data and services.”
Given this direction, you could apply any of the above three options since it’s not always clear where one service ends and another begins. For instance, if the Product
type should be owned solely by the sales team, the first two options would apply. If this type is generic enough that it could be owned by a group of members from both the sales and tutorial teams, the third option could also apply.
None of these options is perfect. Let’s examine the tradeoffs to help decide which is the most suitable for our case.
Autonomous
A discountedPrice
field is generic enough that it could make sense to add it to the Product
type. For instance, let’s say the workshops team also needs to access the discountedPrice
field for its workshops (another type of product) and it must create a new WorkshopProduct
, extending Product
to add it. discountedPrice
will live in several types, leading to duplicated code, breaking the DRY principle, and introducing the possibility of bugs.
Delegation
This option is bureaucratic and may create bottlenecks. For instance, if there is nobody from the sales team available to do the work demanded by the tutorials team, then the schema may not be updated in time. In addition, the communication needed across teams creates overhead (e.g., holding meetings to explain the work, sending emails, etc).
Cross-boundary
Who owns a specific service is less thoroughly defined. It requires better documentation to state which person implemented a given field.
I prefer the cross-boundary option because, unlike the second option, it enables you to iterate quickly to upgrade the schema by avoiding bottlenecks on interteam dependencies while keeping the schema DRY and lean, unlike the first option. The third option might initially seem like the least suitable one concerning the federation principle. However, that’s nothing we can’t fix with some clever architecture.
Now let’s explore the architectural design of a GraphQL server that a) enables people on different teams to contribute to the same schema without overriding each other’s work or building bureaucratic barriers to contribution and b) gives each member ownership over their portion of the schema.
Decentralizing the schema creation
When creating the GraphQL schema, teams must have ownership of their implementations. However, the part of the schema that requires ownership is not defined in advance; it could comprise a set of types, a specific type (Product
), or even a single field within a type (discountedPrice
).
Moreover, different teams could have different requirements for the same field. Take the discountedPrice
field belonging to the Product
type, for instance. While the tutorials team provides a 10 percent discount, the workshops team may provide a 20 percent discount, so the resolution of the field discountedPrice
must be dynamic, dependent on the context.
We don’t want to rename the discountedPrice
field to both discountedPriceForTutorials
and discountedPriceForWorkshops
, respectively, for the two situations. Doing so would make the schema much too verbose, and there is no need to differentiate between discounts in the signature of the field itself. After all, the concept of a discount is the same for all products, so they should all be named discountedPrice
. The product is passed as an argument to the resolver; that will be the differentiating factor at runtime.
For our first iteration, we’ll create a resolver function that resolves differently for different types of products.
const resolvers = {
Product: {
discountedPrice: function(product, args) {
if (product.type == "tutorial") {
return getTutorialDiscountedPrice(product, args);
}
if (product.type == "workshop") {
return getWorkshopDiscountedPrice(product, args);
}
return getDefaultDiscountedPrice(product, args);
}
}
}
In this scenario, the tutorials team owns the function getTutorialDiscountedPrice
, the workshop team owns getWorkshopDiscountedPrice
, and the sales team owns getDefaultDiscountedPrice
. The tutorials teams should also own the line if (product.type == "tutorial") {
, but it currently falls under the sales team. Let’s fix that.
For our next iteration, we’ll create a combineResolvers
function, which, emulating Redux’s combineReducers
function, combines resolvers from different teams. Each team then provides its own resolver implementation for its product type, like this:
// Provided by the tutorials team
const tutorialResolvers = {
Product: {
discountedPrice: getTutorialDiscountedPrice,
}
}
// Provided by the workshop team
const workshopResolvers = {
Product: {
discountedPrice: getWorkshopDiscountedPrice,
}
}
// Provided by the sales team
const defaultResolvers = {
Product: {
discountedPrice: getDefaultDiscountedPrice,
}
}
These are all combined into one:
// Provided by the sales team
const combinedResolvers = combineResolvers(
{
tutorial: tutorialResolvers,
workshop: workshopResolvers,
default: defaultResolvers,
}
)
const resolvers = {
Product: {
discountedPrice: function(product, args) {
const productResolvers = combinedResolvers[product.type] || combinedResolvers["default"];
return productResolvers.Product.discountedPrice(product, args);
}
}
}
This second iteration looks better than the first one, but it still has the same basic problem: the resolver delegator, which is the object combining the resolvers and finding the appropriate resolver for every product type, must know that implementations for tutorials and workshops exist. Since this piece of code is maintained by the sales team, it requires some level of bureaucracy and interteam dependency, which we would rather do away with.
We have a bit more work to do.
Subscribing resolvers
The third and final iteration involves the combination of two design patterns:
- The publish-subscribe design pattern, which decouples the resolver delegator (the “publisher,” owned by the sales team) from the actual resolvers (the “subscribers,” owned by the tutorial and workshop teams)
- The chain-of-responsibility design pattern, which makes the resolver delegator ask every product resolver if it can handle the product or not, one by one in the chain of resolvers, until finding the appropriate one
Each resolver must provide a priority number when being subscribed to the chain. This determines their position on the chain — the higher the priority, the sooner they will be asked if they can handle the product. Then, the default
resolver must be placed with the lowest priority, and it must indicate that it handles products of all types.
I’ve implemented this solution for my own GraphQL server in PHP, so from here out we’ll switch to PHP to demonstrate examples of code.
The only field that is mandatory is the id
field. Otherwise, types are initially empty without any fields at all. The fields are all provided through resolvers, which attach themselves to their intended type.
For our example, we’ll define the Product
type as a TypeResolver
class, implementing only the name of the type (as well as some other information, which is omitted in the code below) and how it resolves its ID.
class ProductTypeResolver extends AbstractTypeResolver
{
public function getTypeName(): string
{
return 'Product';
}
public function getID(Product $product)
{
return $product->ID;
}
}
We’ll then implement instances of FieldResolver
classes, which attach fields to a specific type. The sales team provides an initial resolver that implements all the basic fields, such as name
and price
, and the default implementation for discountedPrice
, giving a discount of 5 percent.
namespace MyCompany\Sales;
class ProductFieldResolver extends AbstractDBDataFieldResolver
{
/**
* Attach the fields to the Product type
*/
public static function getClassesToAttachTo(): array
{
return [ProductTypeResolver::class];
}
/**
* Fields to implement
*/
public static function getFieldNamesToResolve(): array
{
return [
'name',
'price',
'discountedPrice',
];
}
/**
* Priority with which it is attached to the chain.
* Priority 0 => added last
*/
public static function getPriorityToAttachClasses(): ?int
{
return 0;
}
/**
* Always process everything
*/
public function resolveCanProcess(Product $product, string $fieldName, array $fieldArgs): bool
{
return true;
}
/**
* Implementation of the fields
*/
public function resolveValue(Product $product, string $fieldName, array $fieldArgs)
{
switch ($fieldName) {
case 'name':
return $product->name;
case 'price':
return $product->price;
case 'discountedPrice':
// By default, provide a discount of 5%
return $product->price * 0.95;
}
return null;
}
}
Now the tutorials team (and, likewise, the workshops team) can implement its own resolver, which is used only when the product is of type tutorial
.
namespace MyCompany\Tutorials;
class ProductFieldResolver extends AbstractDBDataFieldResolver
{
/**
* Attach the fields to the Product type
*/
public static function getClassesToAttachTo(): array
{
return [ProductTypeResolver::class];
}
/**
* Fields to implement
*/
public static function getFieldNamesToResolve(): array
{
return [
'discountedPrice',
];
}
/**
* Priority with which it is attached to the chain.
* Priority 10 => it is placed in the chain before the default resolver
*/
public static function getPriorityToAttachClasses(): ?int
{
return 10;
}
/**
* Process products of type "tutorial"
*/
public function resolveCanProcess(Product $product, string $fieldName, array $fieldArgs): bool
{
return $product->type == "tutorial";
}
/**
* Implementation of the fields
*/
public function resolveValue(Product $product, string $fieldName, array $fieldArgs)
{
switch ($fieldName) {
case 'discountedPrice':
// Provide a discount of 10%
return $product->price * 0.90;
}
return null;
}
}
Rapid iteration
The beauty of this strategy is that the schema can be dynamic, changing its shape and attributes depending on the context. All you need to do is subscribe an extra resolver to handle a special situation and pluck it out when it’s not needed anymore. This allows for rapid iteration and bug fixing (such as implementing a special resolver just to handle requests from the client who is affected by the bug) without worrying about side-effects somewhere else in the code.
Following our earlier example, the tutorials team could override the implementation of the discountedPrice
field to provide a bigger discount just for this weekend’s flash deal. This was, they can avoid having to bother their colleagues from the sales team on a Saturday night.
namespace MyCompany\Tutorials;
class FlashDealProductFieldResolver extends ProductFieldResolver
{
/**
* Priority with which it is attached to the chain.
* Priority 20 => it is placed at the very beginning!
*/
public static function getPriorityToAttachClasses(): ?int
{
return 20;
}
/**
* Process tutorial products just for this weekend
*/
public function resolveCanProcess(Product $product, string $fieldName, array $fieldArgs): bool
{
$now = new DateTime("now");
$dealStart = new DateTime("2020-03-28");
$dealEnd = new DateTime("2020-03-30");
return $now >= $dealStart && $now <= $dealEnd && parent::resolveCanProcess($product, $fieldName, $fieldArgs);
}
/**
* Implementation of the fields
*/
public function resolveValue(Product $product, string $fieldName, array $fieldArgs)
{
switch ($fieldName) {
case 'discountedPrice':
// Provide a discount of 30%
return $product->price * 0.70;
}
return null;
}
}
Conclusion
Frontend developers enjoy working with GraphQL because it enables them to be autonomous and write a query to fetch the data to power their components from a single GraphQL endpoint — without depending on anyone to provide a required endpoint, as was the case with REST. That is, as long as all resolvers needed to satisfy the query have already been implemented. Otherwise, this may not be true, since someone must still implement those resolvers, and this someone might even belong to a different team, creating some bottlenecks and bureaucracy.
A similar thing happens when creating the GraphQL schema itself: teams must be able to collaborate on a shared, companywide schema, and do so autonomously without depending on other teams to avoid bureaucracy and bottlenecks and provide quick iteration.
You should now have a basic understanding of an architectural strategy to implement resolvers for types, which minimizes interaction across teams while still allowing each team to own its portion of the schema. The resulting architecture provides rapid iteration and allows teams to pluck resolvers in and out of the schema on an ad-hoc basis, making the schema dynamic.
200's only ✅: Monitor failed and show GraphQL requests in production
While GraphQL has some features for debugging requests and responses, making sure GraphQL reliably serves resources to your production app is where things get tougher. If you’re interested in ensuring network requests to the backend or third party services are successful, try LogRocket.
LogRocket is like a DVR for web apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic GraphQL requests to quickly understand the root cause. In addition, you can track Apollo client state and inspect GraphQL queries' key-value pairs.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
The post Speeding up changes to the GraphQL schema appeared first on LogRocket Blog.
Top comments (0)