Federation Foibles
Federation Foibles and Footguns
Table of Contents
Client Normalized Store + Merging
Many of the first-party Apollo GraphQL clients implement a normalized store over objects. Every object fetched as a result of GraphQL queries gets merged into the normalized Apollo store in the clients, which then access that store to render UI elements.
By default, the merging logic merges objects at a field level. For example, if one query returns:
Event {
id: 1,
teams: [Team { id: 2, name: "Super Fish"}, Team {id: 3, name: "Awesome Turtles"}]
}
And another query (or another part of the same query) returns:
Event {
id: 1,
time: "Monday at noon"
}
The resulting store will have:
Event {
id: 1,
teams: [Team { id: 2, name: "Super Fish"}, Team {id: 3, name: "Awesome Turtles"}],
time: "Monday at noon"
}
This is useful because it allows data to load in slices as needed.
The footgun here is how it merges lists.
If one request retrieves:
Event {
id: 1,
teams: [Team { id: 1, name: "Jolly Jaguars"}, Team {id: 2, name: "Super Fish"}],
}
And another query retrieves:
Event {
id: 1,
time: "Monday at noon",
teams: [Team { id: 2, name: "Super Fish"}, Team {id: 3, name: "Awesome Turtles"}],
}
The resulting store will have:
Event {
id: 1,
teams: [Team { id: 2, name: "Super Fish"}, Team {id: 3, name: "Awesome Turtles"}],
time: "Monday at noon"
}
The second list replaces the first — Team { id: 1, name: "Jolly Jaguars" } is silently lost.
Non-specific IDs
A pattern that Federated GraphQL allows is using the @requires directive to require that another subgraph supply certain fields necessary for resolving other fields. An example is the first approach used for resolving markets needed for a ticker card.
We created a MarketComponentQuery type that Tickers would supply on an EventV2. Tickers resolved the type, @requires-d on Sportsbook and @inaccessible to clients. It gave Sportsbook extra parameters needed to resolve a relatedMarkets field on EventV2.
This allowed passing arguments to Sportsbook to provide the event_id and filters for querying Datadex to get the exact markets and selections needed.
The problem is that the client is unaware of this type and runs into a similar issue as described above. If you have two types with the same ID (in this case, Event ID) but different MarketComponentQuery arguments and different sets of relatedMarkets, one object will overwrite the other and the relatedMarkets set won't be correct.
Solution
Don't use this pattern. Ensure that your id is fully unique and that any version of the entity with that ID would have the same values all the way down its tree.
The specific solution chosen was to encode all necessary parameters into the id and base64 encode it. This way the IDs for two versions of the entity are distinct and won't get conflated in the Apollo Store.
Federated Subscriptions - What Updates
Federated subscriptions are only updated when the service handling the root subscription updates. This has characteristics that API consumers may not expect.
Example: If you subscribe to a market and include data for the team and their logo, the subscription will not receive a new logo when that changes. If a market update happens it will cause it to re-fetch the team and their logo — which may have changed in the meantime — but subscriptions update only when the root system triggers an update.
Recommendations
-
Name subscriptions specifically for what is updated in them. For example:
MarketUpdatedSubscription. -
Create new types for the data you are returning.
- This may not be ideal for every type, but you could return a
MarketUpdatetype rather than aMarkettype. - The new type would only include fields that the root subgraph can provide.
- This puts extra work on the client to merge updates into the store, but the implications are clear.
- This may not be ideal for every type, but you could return a
-
Have the backend publish updates when other potential pieces of the object tree update.
- For example, Sportsbook could have a Kafka consumer that consumes team logo updates and republishes all markets.
- This gives the effect of the subscription updating when any part of it updates.
- It requires the backend supporting the root query to know about all possible child types provided by other subgraphs — not terribly maintainable.
- Has performance implications for the root query subgraph.
- May be useful in specific scenarios.
AVOID Using Interfaces in Federated Schemas to Enforce Field "Contracts"
GraphQL interfaces — and in particular federated Entity Interfaces (interface types that have a @key directive) — can become difficult to manage.
Alternative: Leverage unions where possible. If you have an interface that defines some fields, consider adding a type to represent those common fields, and a union of "extension types" to handle the differences.
Avoid:
interface Player {
fullName: String!
}
type BaseballPlayer implements Player {
fullName: String!
battingAverage: Integer!
}
type FootballPlayer implements Player {
fullName: String!
touchdownsScored: Integer!
}
Consider:
type Player {
fullName: String!
playerExtensions: PlayerData
}
union PlayerData = FootballPlayerData | BaseballPlayerData
type FootballPlayerData {
touchdownsScored: Integer!
}
type BaseballPlayerData {
battingAverage: Integer!
}
Acceptable Use: If the interface provides value from the perspective of the consumer, feel free to add it. One way to verify this is to look at whether the interface is used as an output type for queries or mutations, and whether the client will have to specify fragments for implementations of that interface.
AVOID Using Directives that Introduce Dependencies Between Subgraph Schemas
Certain federated GraphQL directives introduce dependencies between subgraph schemas. Directives that introduce coupling should be used only as a last resort, to avoid consequences that can include:
- Preventing one subgraph from evolving independently
- Introducing deployment dependencies
- Reducing team autonomy by introducing development dependencies
These problems are especially pernicious during the development phase, when schemas can evolve and revert far more frequently than they would in production.
@interfaceObject — This directive allows you to extend an interface owned by another subgraph. To use it, ensure that the interfaces being extended are stable. There has been at least one instance where during development the team decided to remove an interface, causing friction across teams — including rework and coordinated deployments across each environment.
Alternative: Consider extending the underlying interface implementations as entities in your subgraph.
@external / @requires — Both of these directives specify field dependencies on fields defined by other subgraphs.
Alternative: Architect your solution so that the underlying data values that are
@requiredare available to your subgraph directly. In many environments, underlying values are available in shared Datadex instances, making this a viable solution for many areas. Or, write a Kafka consumer for the relevant topics and store the necessary values locally, removing the dependency on values resolved by other services.Acceptable Use: If another subgraph produces a value using business logic that you do not want to replicate in your subgraph, this pair of directives may be an acceptable path forward.
HTTP/Multipart Subscription Limit
HTTP/Multipart subscriptions create a separate request for every subscription. These requests use http2 as their transport. Each request is a "stream" in http2 parlance. While the spec doesn't specify an upper bound to the number of concurrent streams over one connection, in practice there is a limit. The spec allows clients and servers to negotiate this limit based on the SETTINGS_MAX_CONCURRENT_STREAMS setting.
In practice, most browsers and platforms limit this to 100. There may be ways to override this from the server side, but this is inadvisable — it's unclear whether a server-side override would properly impact all clients (all browsers, all mobile HTTP implementations, etc.). Treat 100 as the lowest common denominator and the practical upper limit.
Once the number of streams is exhausted, any further requests will block — including standard request/response calls (like GET and POST) — until some of those 100 streams free up.