11. Resolver chains
20m

Overview

We can for a listing's amenities, but only through the listing(id: ID) root , not through featuredListings. What's going on?

In this lesson, we will:

  • Learn about chains
  • Learn about the source of a datafetcher method
  • Pass local context from one datafetcher method to the next

Examining the data source response

Let's examine the response from our GET /featured-listings endpoint again. Open a new browser tab, and paste in the URL below.

The GET /featured-listings endpoint
https://rt-airlock-services-listing.herokuapp.com/featured-listings

The array that we get back contains the listings objects that we expect; but we'll notice that one property in each listing object is different! From the /featured-listings endpoint, a listing's list of "amenities" is an array that contains just the identifier for each amenity. No additional data!

This is a common pattern in REST APIs. Imagine if the amenities list for each listing included the full data for each amenity. That would make for a very large response, to have a list of listings and a list of full amenities for each listing! The problem would compound if the endpoint decided to return even more featured listings than the three we already have.

To make sure that we can return featured listings along with all the data for their amenities, we'll need to make one more additional call to the REST API. In this case, to a new endpoint: GET /listings/{listing_id}/amenities.

The next question becomes: where in our code will we make that call?

Accounting for missing amenities

To account for our missing amenities, we could update our featuredListings datafetcher method. We might give it some extra logic to reach out to GET /listings/{listing_id} to fetch this extra data.

A suggestion for implementing the follow-up call
@DgsQuery
public List<ListingModel> featuredListings() throws IOException {
List<ListingModel> listings = listingService.featuredListingsRequest();
// traverse listings for each listing id?
// make a follow-up request to /listings/{listing_id}/amenities
// then recompose each ListingModel object with the new amenities data?
return listings;
}

Well, this would work; but it would mean that every time we for featuredListings, we would always make an additional network call to the REST API, whether the asked for a listing's amenities or not.

So instead, we're going to make use of the chain.

Following the resolver chain

A resolver chain is the order in which datafetcher methods (known in some other frameworks as resolver functions) are called when resolving a particular . It can contain a sequential path as well as parallel branches.

Let's take an example from our project. This GetListing retrieves the title of a listing.

query GetListing($listingId: ID!) {
listing(id: $listingId) {
title
}
}

When resolving this , the will first call the Query.listing() datafetcher method, which returns a Listing type, then the Listing.title() method which returns a String type and ends the chain.

Resolver chain in a diagram

Note: We didn't need to define a separate datafetcher method for Listing.title because the title property can be returned directly from the instance returned by Query.listing.

Each datafetcher method in this chain passes their return value down to the next method as a property on a large object called the DgsDataFetchingEnvironment.

The DgsDataFetchingEnvironment is optional for a datafetcher method to use, but it contains a lot of information about the being executed, the server's context, as well as the parameter we're concerned with: source.

In this example, the Listing.title() datafetcher method could use the DgsDataFetchingEnvironment's source property to access the Listing object the Query.listing() method returned.

Let's look at another .

query GetListingAmenities($listingId: ID!) {
listing(id: $listingId) {
title
amenities {
name
}
}
}

This time, we've added more and asked for each listing's list of amenities, specifically their name values.

Our chain grows, adding a parallel branch.

Resolver chain in a diagram

Because Listing.amenities returns a list of potentially multiple amenities, this might run more than once to retrieve each amenity's name.

Following the trail of the , Listing.amenities() would have access to Listing as the source, just as Amenity.name() would have access to the Amenity object as the source.

If our didn't include the amenities (like the first example we showed), then the Listing.amenities() method would never be called!

The Listing.amenities datafetcher method

Now that we know what a chain is, we can use it to determine the best place to insert the additional REST API call for a listing's amenities.

Remember, we were debating including it in the Query.featuredListings datafetcher method, where it would be called every single time we for featured listing data, even when the doesn't include the amenities :

The featuredListings datafetcher, as it should be
@DgsQuery
public List<ListingModel> featuredListings() throws IOException {
// Not the best place to make an extra network call!
return listingService.featuredListingsRequest();
}

Instead, we'll add a new datafetcher method to ListingDataFetcher class—one that's specifically responsible for fulfilling the Listing.amenities from our schema.

For our last two datafetcher methods, we used the @DgsQuery annotation. That's because the methods we defined were responsible for providing data for on our schema's Query type.

This time, however, we want to define a method that's responsible for fulfilling data for a on the Listing type. To do this, we'll need to reach for a different annotation: @DgsData.

schema.graphqls
type Listing {
id: ID!
# ... other Listing fields
"The amenities available for this listing"
amenities: [Amenity!]! # We want to define a datafetcher method for THIS field!
}

The @DgsData annotation

The @DgsData annotation lets us specify the type and we're defining the method for. Here's what that will look like for the Listing.amenities :

@DgsData(parentType="Listing", field="amenities")

And if we give our method the same name as the , amenities, we can omit the field="amenities" specification here in the annotation.

Let's define this method in our ListingDataFetcher class now.

ListingDataFetcher
@DgsData(parentType="Listing")
public void amenities() {}

And we'll import the new @DgsData annotation at the top, along with our server's generated Amenity type, which we'll use momentarily.

ListingDataFetcher
import com.netflix.graphql.dgs.DgsData;
import com.example.listings.generated.types.Amenity;

Returning Listing.amenities

Right away, we can update the return type for our method to be a List of Amenity types.

ListingDataFetcher
@DgsData(parentType="Listing")
public List<Amenity> amenities() {
// TODO
}

Now, it's time to make use of that source we mentioned earlier in the lesson. The source, remember, is the listing instance that we're resolving amenities for; it's the value returned by the previous datafetcher method.

We haven't defined separate datafetcher methods for a Listing type's id, title, and so on, so our server will look for these property on each ListingModel instance that is returned when we for a listing or featured listings. Our Listing.amenities , however, now has its own datafetcher method. Rather than just checking the ListingModel instance for its amenities property, our server will rely on this method to provide the final say on what data is returned for a listing's amenities.

To make its job easier, this method receives the ListingModel it's resolving amenities for as the source property on DgsDataFetchingEnvironment. This lets us access and use ListingModel properties—such as its id—to make our follow-up request possible.

Let's import DgsDataFetchingEnvironment at the top of the file.

ListingDataFetcher
// ... other imports
import com.netflix.graphql.dgs.DgsDataFetchingEnvironment;

Next, we'll add it as an to our method called dfe.

ListingDataFetcher
@DgsData(parentType="Listing")
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) {
// TODO
}

To access the source property, we'll call dfe.getSource(). We'll receive this value as a ListingModel type called listing.

ListingDataFetcher
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) {
ListingModel listing = dfe.getSource();
}

Next, we'll access the id property from the listing.

ListingDataFetcher
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) {
ListingModel listing = dfe.getSource();
String id = listing.getId();
}

So, how do we determine whether the ListingModel instance we're resolving already has all the amenity data attached to it? We know that when we for a single listing, we get all the amenity data from the REST API; in contrast, when we query for featuredListings, we only get a list of amenity ids for each listing; name and category are null! They weren't included in the REST API response.

// Amenities on an instance of ListingModel when returned by the Query.listing datafetcher
Listing{id='listing-1',
amenities='[Amenity{id='am-2',category='Accommodation Details',name='Towel'},
Amenity{id='am-10',category='Space Survival',name='Oxygen'},
Amenity{id='am-11',category='Space Survival',name='Prepackaged meals'}]'}
// Amenities on an instance of ListingModel when returned by the Query.featuredListings datafetcher
Listing{id='listing-1',
amenities='[Amenity{id='am-2',category='null',name='null'},
Amenity{id='am-10',category='null',name='null'},
Amenity{id='am-11',category='null',name='null'}]}

Local context in DGS

DGS gives us a way to pass down custom context between our datafetchers: using the DataFetcherResult type. We'll use this type's localContext property to help us determine whether we're resolving amenities for a ListingModel that already has them set on the class, or whether we need to make a follow-up request.

First, let's import the DataFetcherResult type, along with Java's Map utility, at the top of the file.

ListingDataFetcher
import graphql.execution.DataFetcherResult;
import java.util.Map;

Then, we'll wrap the listing method's return type with DataFetcherResult.

ListingDataFetcher
@DgsQuery
public DataFetcherResult<ListingModel> listing(@InputArgument String id) {
return listingService.listingRequest(id);
}

Right now, we'll see an error in our code: the listing method is no longer returning the type that we've indicated. Instead of returning the data directly, we'll capture it in a ListingModel called listing.

ListingDataFetcher
@DgsQuery
public DataFetcherResult<ListingModel> listing(@InputArgument String id) {
ListingModel listing = listingService.listingRequest(id);
}

To return a DataFetcherResult type, we'll need to build an instance of the type, with additional properties attached. The syntax will look something like this:

return DataFetcherResult.<T>newResult()
.data() // pass in the data to return
.localContext() // attach local context
.build();

Let's update our method using this syntax. For the value of T, the type , we'll give the original return type of our method, ListingModel. We'll pass the listing into data().

ListingDataFetcher
return DataFetcherResult.<ListingModel>newResult()
.data(listing)
.localContext()
.build();

The localContext property is what we'll use in this datafetcher method to indicate to the next datafetcher in the chain that the ListingModel we return already has its full amenity data.

To do so, we'll pass in a new Map with a property hasAmenityData that we set to true.

ListingDataFetcher
return DataFetcherResult.<ListingModel>newResult()
.data(listing)
.localContext(Map.of("hasAmenityData", true))
.build();

Now let's do the same for featuredListings. The syntax will look the same, except for the localContext value, where we'll set the hasAmenityData property to false.

ListingDataFetcher
@DgsQuery
public DataFetcherResult<List<ListingModel>> featuredListings() throws IOException {
List<ListingModel> listings = listingService.featuredListingsRequest();
return DataFetcherResult.<List<ListingModel>>newResult()
.data(listings)
.localContext(Map.of("hasAmenityData", false))
.build();
}

Retrieving context with getLocalContext

Our Query.listing and Query.featuredListings datafetcher methods are both setting some custom context; now, let's set up the syntax that lets us retrieve that context from another datafetcher method.

Scroll back to the Listing.amenities datafetcher method.

We'll use the dfe parameter again, this time calling getLocalContext. We'll receive this as a Map<String, Boolean> type we'll call localContext.

ListingDataFetcher
Map<String, Boolean> localContext = dfe.getLocalContext();

We'll check whether localContext exists and hasAmenityData on localContext is true, and if so, we can safely assume that our ListingModel instance already has its amenities property set and fully populated.

ListingDataFetcher
Map<String, Boolean> localContext = dfe.getLocalContext();
if (localContext != null && localContext.get("hasAmenityData")) {
return listing.getAmenities();
}

Now if our local context doesn't have hasAmenityData set to true, then we'll assume that a follow-up request for complete amenity data is necessary.

ListingDataFetcher
Map<String, Boolean> localContext = dfe.getLocalContext();
if (localContext != null && localContext.get("hasAmenityData")) {
return listing.getAmenities();
}
// TODO: FOLLOW-UP REQUEST HERE

Let's build the call in ListingService that can request amenity data.

Requesting amenities

Now let's jump into our datasources/ListingService file and build out this method. First, import the Amenity type from our generated folder.

datasources/ListingService
// ... other imports
import com.example.listings.generated.types.Amenity;

We'll need this method to return a List of Amenity types, since that's what our Listing.amenities expects to return. It will receive the id of the listing that we want to retrieve amenities for, a String we'll call listingId.

datasources/ListingService
public List<Amenity> amenitiesRequest(String listingId) {
// TODO
}

We'll start this request with the same boilerplate we've used previously: calling get on our class' client instance, and chaining on the uri. We're reaching out to the /listings/{listing_id}/amenities endpoint, passing in the listingId as the value for {listing_id}.

datasources/ListingService
client
.get()
.uri("/listings/{listing_id}/amenities", listingId)

Next, we'll chain on retrieve and body.

datasources/ListingService
client
.get()
.uri("/listings/{listing_id}/amenities", listingId)
.retrieve()
.body()

Because the response from this endpoint contains a list of objects that match our Amenity class, we can first return the whole response as a JsonNode, then call our mapper.readValue method again.

Next, we'll map through each of the objects in the array, creating an instance of Amenity out of each. Try it out, following the same steps we implemented in the featuredListingsRequest method. When you're ready, compare your method against the final state below!

datasources/ListingService
public List<Amenity> amenitiesRequest(String listingId) throws IOException {
JsonNode response = client
.get()
.uri("/listings/{listing_id}/amenities", listingId)
.retrieve()
.body(JsonNode.class);
if (response != null) {
return mapper.readValue(response.traverse(), new TypeReference<List<Amenity>>() {
});
}
return null;
}

Finishing the datafetcher

Let's wrap up our datafetcher by calling our new method. Back in ListingDataFetcher, in the amenities method, we'll use the listing's id as an and return the results of calling listingService.amenitiesRequest.

ListingDataFetcher
@DgsData(parentType="Listing")
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) throws IOException {
ListingModel listing = dfe.getSource();
String id = listing.getId();
Map<String, Boolean> localContext = dfe.getLocalContext();
if (localContext != null && localContext.get("hasAmenityData")) {
return listing.getAmenities();
}
return listingService.amenitiesRequest(id);
}

Just one last thing to take care of! Our ListingService request for amenities could result in a possible thrown exception, so let's account for this exception.

ListingDataFetcher
@DgsData(parentType = "Listing")
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) throws IOException {
// ... amenity fetching logic!
}
Task!

Explorer time: round 2!

Server restarted, and running with the latest changes? Great! Now when we jump back over to Sandbox and run the for featuredListings and its list of amenities, we get what we asked for!

query GetFeaturedListings {
featuredListings {
id
title
description
amenities {
id
name
category
}
}
}

👏👏👏

Comparing with the REST approach

Time to put on our product app developer hat again! Let's compare what this feature would have looked like if we had used REST instead of .

If we had used REST, the app logic would have included:

  • Making the HTTP GET call to the /featured-listings endpoint
  • Making an extra HTTP GET call for each listing in the response to GET /listings/{listing_id}/amenities. Waiting for all of those to resolve, depending on the number of listings, could take a while. Plus, this introduces the common N+1 problem.
  • Retrieving just the id, name and category properties, discarding the rest of the response. Depending on the response, this could mean we fetch a lot of data that's not used! And big responses come with a cost.

With , the client writes a short and sweet, clean, readable , and the data returns in exactly the shape they specified, no more, no less!

All the logic of extracting the data, making extra HTTP calls, and filtering for which are needed are all done on the side. We still have the N+1 problem, but it's on the server-side (where response and request speeds are more consistent and generally faster) instead of the client-side (where network speeds are and inconsistent).

Note: We can address the N+1 problem on the side using Data Loaders, which we cover in an upcoming course.

Key takeaways

  • A chain is the order in which datafetcher functions are called when resolving a particular . It can contain a sequential path as well as parallel branches.
  • Each datafetcher method in this chain passes their return value to the next method as the source property on a large object called the DgsDataFetchingEnvironment.
  • The DgsDataFetchingEnvironment object is an optional parameter that all datafetcher methods have access to. It contains the return value of the previous datafetcher called in the chain, as well as other data about the being executed.
  • We can use DGS' DataFetcherResult to customize the value a datafetcher method returns, attaching local context that the next datafetcher method in the chain can access.

Up next

Feeling confident with queries? It's time to explore the other side of : .

Previous

Share your questions and comments about this lesson

This course is currently in

beta
. Your feedback helps us improve! If you're stuck or confused, let us know and we'll help you out. All comments are public and must follow the Apollo Code of Conduct. Note that comments that have been resolved or addressed may be removed.

You'll need a GitHub account to post below. Don't have one? Post in our Odyssey forum instead.