Overview
Now that our subscription is working (and has been filtered), let's broaden our focus. So far, we've been working with just a single subgraph: so, do we get any actually benefit from using a federated graph, rather than a single GraphQL server? Let's dive in!
In this lesson, we will:
- Talk about the subscription process in a federated graph
- Look at operations that involve fields from multiple subgraphs
- "Subscribe" to updates across the graph by using fields from a different subgraph
Updates from across the graph
Let's review what our router's doing when we send it a subscription operation. Recall that the router first devises its query plan—the set of instructions it follows to resolve the query. This can involve making multiple smaller requests to multiple different subgraphs, but so far we see that our router's sending a request to just one: the messages
subgraph.
So why don't we just send our client operations directly to the messages
subgraph? After all, the router looks like just another stop on the way to data.
Well, it's important to remember that when we use the router to execute operations against our federated graph, we get the benefit of our entire API at our fingertips. So we might instead build a subscription operation to query data from across our graph: we'll subscribe to one single aspect of our schema (each message as it's added to a conversation), but we can bring other data from across our other subgraphs along for the ride.
Here's one way we might build out our subscription operation to include fields from other subgraphs.
subscription SubscribeToMessagesInConversation($listenForMessageInConversationId: ID!) {listenForMessageInConversation(id: $listenForMessageInConversationId) {textsentTimesentTo {idnameprofileDescription}sentFrom {idname}}}
We've modified our operation to include the additional Message
type fields sentTo
and sentFrom
. Both of these fields return a User
type, from which we can select additional subfields. But if we jump into our messages
subgraph schema, we'll find that most of these fields (name
, profileDescription
) are not resolved here; they're actually the responsibility of our accounts
subgraph. Because the User
type is an entity, it has fields in different subgraphs.
If we ran this new subscription operation, the router would have two stops to make for every new event: it would receive the details of the new message submitted, and could provide the values right away for the text
and sentTime
fields. But to provide the data for sentFrom
and sentTo
, it would need to take both user IDs and make an additional request to accounts
. Once all the details are collected, the router would bundle the data together and return it to the client.
This is pretty cool, because it means that every time our subscription picks up a new message, the router makes sure that we request the data for each user involved in the conversation.
But—there's a flipside. Do we want to re-request each user's id
, name
, and sometimes even profileDescription
each time we get a new message? Well... probably not. Those fields are unlikely to change frequently enough to warrant refreshing the data with each new message event. Instead, we might want to focus on other fields in our graph that do change more frequently. Let's explore a better use case for our subscription operation next.
Adding the isOnline
field
Rather than filling up our subscription operation with all sorts of static fields from across our graph, we'll keep it focused.
We want this operation to be responsible for notifying us of each new message as soon as it's sent and saved in the database. Focusing on the "realtime" nature of our messages
subgraph, we might also be interested to know whether our message recipient is online. This is a status that can change from moment to moment, which makes it a good candidate to include in our operation.
subscription SubscribeToMessagesInConversation($listenForMessageInConversationId: ID!) {listenForMessageInConversation(id: $listenForMessageInConversationId) {textsentTimesentTo {idisOnline}}}
There's just one problem: our User
type doesn't actually have an isOnline
field that we can use. Let's add one!
Jump into your messages
subgraph, and open up src/schema.graphql
. Let's add this field to our User
type, with a return type of non-nullable Boolean
.
type User @key(fields: "id") {id: ID!"The status indicating whether a user is online"isOnline: Boolean!}
The big question is: what determines whether someone is online?
Considerations for isOnline
status
When deciding whether someone is "online", we'll use two factors: whether they're logged in and whether they have been active within some recent time period.
There are two fields that can help us here: isLoggedIn
and lastActiveTime
. Both of these fields exist on the User
type, but they exist in the accounts
subgraph! And because the isOnline
field is relevant to our chat feature, it makes sense to keep it within the messages
subgraph.
# User definition in MESSAGEStype User @key(fields: "id") {id: ID!"The status indicating whether a user is online"isOnline: Boolean! # ⬅️ How do we determine true/false?}# User definition in ACCOUNTStype User @key(fields: "id") {id: ID!# ... other fields"The last recorded activity timestamp of the user"lastActiveTime: String"Whether or not a user is logged in"isLoggedIn: Boolean!}
So how exactly can we use lastActiveTime
and isLoggedIn
(provided by a completely separate subgraph) to determine the value of isOnline
? Directives to the rescue!
@requires
and @external
Federation directives are special instructions we can include in our schema to tell the router to do something specific. We've already seen one of these directives, @key
, which is used to indicate the primary key(s) that we can use when referring to an entity instance across our graph.
There are two additional directives that we'll put to work in our messages
subgraph: @requires
and @external
.
Let's scroll to the top of our schema.graphql
file and add them to our federation import.
extend schema@link(url: "https://specs.apollo.dev/federation/v2.8"import: ["@key", "@requires", "@external"])
Let's walk through what both of these directives can do for us.
@requires
The @requires
directive is used to indicate that the field requires one or more values from other fields before it can return its own data. It tells the router that a particular field cannot return a value until it has the values of another field (or fields) first.
Let's take a look at the syntax by applying it to our isOnline
field.
type User @key(fields: "id") {id: ID!isOnline: Boolean! @requires}
After @requires
, we pass a set of parentheses (()
) where we include the names of the fields that are "required". In our case, we want to first know a user's lastActiveTime
and isLoggedIn
field values before we make any determination about whether they're online or not. We'll pass these field names as a single string (separated with a space) to a property called fields
.
type User @key(fields: "id") {id: ID!isOnline: Boolean! @requires(fields: "lastActiveTime isLoggedIn")}
That's it for @requires
—but we have a few more schema updates to make. Our messages
subgraph still has no idea what these required fields are, or where they come from. This is where @external
shines!
@external
We use the @external
directive when we need a subgraph schema to reference a field it's not actually responsible for resolving. Our current situation is a great example of this: one of our fields requires the values of two other fields before it can return data. But those fields don't exist within the messages
subgraph; they're external fields.
To fix this problem, and keep our subgraph from getting confused, we'll update our User
definition with both of these required fields. They should be exactly the same as they're defined in the accounts
subgraph, with one difference: we'll tack on the @external
directive to both.
type User @key(fields: "id") {id: ID!isOnline: Boolean! @requires(fields: "lastActiveTime isLoggedIn")"The last recorded activity timestamp of the user"lastActiveTime: String @external"Whether or not a user is logged in"isLoggedIn: Boolean! @external}
Walking through the operation
So how exactly does this change the way the router resolves our operation? Let's walk through it step-by-step.
subscription SubscribeToMessagesInConversation($listenForMessageInConversationId: ID!) {listenForMessageInConversation(id: $listenForMessageInConversationId) {textsentTimesentTo {idisOnline}}}
To resolve this operation, the router establishes the subscription with messages
via the Subscription.listenForMessageInConversation
field. With every new message event, it returns the message's text
and sentTime
fields.
But when it gets to the sentTo
field, the router sees that there's additional information requested; we want to know whether the recipient of the message is online. In order to fetch this field from messages
, the router sees that it first needs to go and fetch the lastActiveTime
and isLoggedIn
fields from the accounts
subgraph! (The isOnline
field, after all, requires them to do its job!)
The sentTo
field returns a User
type, and the router uses the id
(the User
type's primary key) to make a request to the accounts
subgraph for the same user's lastActiveTime
and isLoggedIn
values.
The accounts
subgraph uses that provided id
to look up the corresponding User
, and it resolves the values for lastActiveTime
and isLoggedIn
, returning them to the router.
Now the router has the required values in hand! It returns to the messages
subgraph, passing along not just the User
type's id
field, but lastActiveTime
and isLoggedIn
as well. (This is called the entity representation, the object that the router uses to associate objects between subgraphs.)
Now the messages
subgraph has all the data it needs to resolve isOnline
. Inside the isOnline
resolver, we can pluck off the lastActiveTime
and isLoggedIn
fields and write the logic to determine conclusively whether a user should appear as online or not.
Now that we've seen how we get access to lastActiveTime
and isLoggedIn
, let's use those values in the isOnline
resolver to return true or false!
Updating resolvers
In the resolvers/User.ts
file, make some space for a new resolver function called isOnline
.
User: {__resolveReference: async ({ id, ...attributes }, { dataSources }) => {const user = await dataSources.db.getUserDetails(id)return { ...attributes, ...user, id: user.username }},isOnline: () => {}}
We'll start by destructuring the first positional argument, parent
, for those two fields that we required from the accounts
subgraph: lastActiveTime
and isLoggedIn
.
isOnline: ({ lastActiveTime, isLoggedIn }) => {// TODO},
Next we'll paste in some logic. This checks whether the user is logged in AND if they've been active in the last five minutes.
const now = Date.now();const lastActiveDate = new Date(lastActiveTime).getTime();const difference = now - lastActiveDate;if (isLoggedIn && difference < 300000) {return true;}return false;
Try it out!
Let's see our subscription in action: this time, with data coming from two parts of our graph. Open up http://localhost:4000 where our rover dev
process should still be running the local router.
We'll set up our subscription to the same conversation, and send a message.
Step 1: Start the subscription
Open up a new tab and paste in the following subscription operation:
subscription SubscribeToMessagesInConversation($listenForMessageInConversationId: ID!) {listenForMessageInConversation(id: $listenForMessageInConversationId) {textsentTimesentTo {idisOnline}}}
And in the Variables tab:
{"listenForMessageInConversationId": "wardy-eves-chat"}
Also, make sure that your Headers tab reflects the following:
Authorization: Bearer eves
Step 2: Send a message
Next let's send a message to that conversation. You know the drill! Open up a new tab, add the operation from your Operation Collection, or paste the operation below.
mutation SendMessageToConversationIncludeOnline($message: NewMessageInput!) {sendMessage(message: $message) {textsentTo {idnameisOnline}}}
And in the Variables panel:
{"message": {"text": "Hey there, thanks so much for booking!","conversationId": "wardy-eves-chat"}}
Make sure that your Headers tab includes the values specified previously. Submit the operation! We should see the new subscription event, along with the usual mutation response.
Step 3: Change the recipient's online status!
Now let's toggle the online status of our recipient. Open up a new tab; this time we'll "log in" as our recipient, wardy
.
Add the following operation:
mutation ToggleUserLoggedIn {changeLoggedInStatus {timesuccessmessage}}
And in the Headers tab, make sure that your Authorization
header now specifies wardy
. (We want to log the recipient of our message in/out!)
Authorization: Bearer wardy
Step 4: Send another message, toggle status, and repeat!
Return to the tab where you've built the SendMessageToConversationIncludeOnline
operation—and send another message! (Here, you should be authorized as eves
.)
This time, you should see in the Subscriptions box that the recipient's isOnline
status has changed! Jump back to the tab with ToggleUserLoggedIn
and toggle their status once more. When you go back to send another message, you should see that their status has changed again!
With every new message we send or receive, we're getting a fresh update of the user's "online" status; all through using schema directives, and the power of the router!
Practice
Use the following code snippets to answer the multiple choice question.
type Subscription {listenForNewReviewOnListing(id: ID!): Review}type Review {review: String!rating: Int!author: User!listing: Listing!}# Other fields exist in the `listings` subgraphtype Listing @key(fields: "id") {id: ID!}# Other fields exist in the `accounts` subgraphtype User @key(fields: "id") {id: ID!}
subscription SubscribeToNewReviews($listenForNewReviewOnListingId: ID!) {listenForNewReviewOnListing(id: $listenForNewReviewOnListingId) {reviewratingauthor {idname}listing {title}}}
SubscribeToNewReviews
operation with each new subscription event?Key takeaways
- Subscription operations can include not only the single "realtime" field that frequently changes, but other fields from across our graph as well.
- Each time a new subscription event occurs, the router will refetch the data for all fields included in the operation, and return it all at once.
- We can use federation directives, such as
@requires
and@external
, to indicate that one entity field requires the value (or values) of another field before it can return data.
Journey's end
And with that, you've made it to the end! You've implemented federated subscriptions from start to finish, moving from development to production without missing a beat. Excellent job!
That's not all though: we've barely scratched the surface of the tooling available to you when you run a graph through GraphOS Studio and the router. Keep an eye out for the second part in this series, Subscription Observability, coming soon on Odyssey! Thanks for joining us in this one—we're looking forward to our next journey together.
Share your questions and comments about this lesson
This course is currently in
You'll need a GitHub account to post below. Don't have one? Post in our Odyssey forum instead.