This article is part of an ongoing series on conceptualizing, designing, and implementing a GraphQL server. The previous articles from the series are:
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:
TutorialProduct
type that extends from the Product
type and add the field under this typeProduct
typeProduct
typePrincipled 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.
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.
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).
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.
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.
The third and final iteration involves the combination of two design patterns:
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 MyCompanySales; 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 MyCompanyTutorials; 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; } }
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 way, they can avoid having to bother their colleagues from the sales team on a Saturday night.
namespace MyCompanyTutorials; 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; } }
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.
LogRocket is like a DVR for web and mobile 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.Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
2 Replies to "Speeding up changes to the GraphQL schema"
regarding discountedPrice example. I think create 2 different Product types instead of create 2 branches in discountedPrice in the same type is more clear to me. Because these 2 types of Products should belongs to 2 different Bounded Context(https://martinfowler.com/bliki/BoundedContext.html). As time goes on, the 2 branches will grow. It won’t look as simple as it was created at the beginning. And there will be more fields like discountedPrice in the Product type. The logic will be more verbose and might need to be decoupled. So I don’t think using the same discountedPrice to handle the requirements of tutorial team and workshop team is a good idea. Though this is a great post!
Actually, i prefer multiple graphql layers to handle the issues in this post. there will be 2 graphql layers in companywide . the first layer just combine different data sources including: DB, RESTFul, etc. but don’t do any modification. the 2nd layer built on the 1st layer which is a BFF(https://www.infoq.com/presentations/graphql-bff/) layer which only service those front-ends.