Contracts Usage Patterns
Deliver use-case-specific schemas to different graph consumers
Want to learn about schema design in-person?
Don't miss the Schema design excellence workshop at this year's GraphQL Summit.
💡 TIP
If you're an enterprise customer looking for more material on this topic, try the Enterprise best practices: Contracts course on Odyssey.
Not an enterprise customer? Learn about GraphOS for Enterprise.
Contracts enable teams to contribute to a single unified graph while also delivering use-case-specific schemas to different types of consumers. Graph administrators define filter rules to generate contract schemas that are a strict subset of the source supergraph.
There are three different filtering strategies you can use for a contract:
- Selective exclusion (pruning)
- Selective inclusion (deny by default)
- Combined exclusion and inclusion
The sections below cover when to use each strategy and why.
Selective exclusion (pruning)
Defining an "excludes" filter for a contract requires the least work, however it also carries the highest risk of exposing new schema fields if you forget to tag them appropriately. We recommend this strategy only in cases where accidental exposure of sensitive fields is acceptable (such as in a beta API), or if your organization has strict governance and review policies in place.
As an example, let's look at a single subgraph using a tag internal
to denote internal-only fields.
type User {id: ID!email: String!username: String!description: StringsupportNotes: [String!]! @tag(name: "internal")supportLevel: Int!}
It's likely that supportLevel
has the same sensitivity as supportNotes
, but the developer left off the tag, so it's now exposed in the contract.
As a result, Apollo finds this approach best when it's acceptable to have fields exposed in the contract, such as the case with beta or experimental APIs where having the beta fields in the production variant wouldn't cause concern.
Selective inclusion (deny by default)
Although the "includes" filter can be more cumbersome to use than "excludes," we recommend using it to create contracts in most cases for the following reasons:
- It's more intuitive to reason with when working with the schema SDL. Having clear markings on which fields are exposed, alongside clear naming conventions, provides an improved developer experience when working with contracts.
- It's much more secure when compared to the "exclude" strategy, given that new and potentially sensitive schema fields must be explicitly added to a contract.
To demonstrate why this is the case, let's take a look at the same type as before, but using an "include" model. This time, the company opted to use an external
tag to denote which fields are exposed to external consumers:
type User {id: ID! @tag(name: "external")email: String! @tag(name: "external")username: String! @tag(name: "external")description: String @tag(name: "external")supportNotes: [String!]!supportLevel: Int!}
Instead of tagging the two support
fields to exclude them, we now explicitly denote which fields are exposed externally. This also more clearly conveys the intent of the tag—a developer would know that the id
is exposed to external parties.
If the team wants to expose a new field and forgets to tag it, it is not included in the resulting contract variant.
As a result, we recommend this approach when building anything that requires that only specific fields are exposed to another audience, such as external or partner APIs.
Combined exclusion and inclusion
The last of the three options has specific use cases, but these are much more dependent on the schema you're working with. This filter method allows you to set a filter of included tags, but then refine that result by removing others.
We recommend considering this if you need to broadly include types (such as the above User
type) but need to refine further with removing other fields. As a result, this typically works in the same situations as the exclude approach, but has some benefits by allowing you to explicitly defining types/fields that should be included explicitly alongside it avoiding some of the pitfalls of the exclude-only approach.
As such, make sure to treat this as a combination of both of the previous approaches:
- Because you still rely on an exclude filter to refine the results, treat this approach much like the exclude approach discussed above. It's possible to accidentally include fields as a result of still further refining using an exclude filter.
- Use the include filter to ensure only the types/fields are being exposed that you want to expose.
To wrap up the examples, let's look at the previous example where we've refactored to use this filtering approach:
type User @tag(name:"external") {id: ID!email: String!username: String!description: Stringsupport: SupportInformation! @tag(name: "internal")}type SupportInformation: @tag(name:"internal") {level: Int!supportNotes: [String!]!}
Now that we've created a new type to house the support information, we tag that type with the internal
tag. We'd use this tag to exclude the type from the resulting external API, but the general User
type would be included without the support
field. If the support
field is not also tagged as internal
, it results in a composition error, providing some safety.
By allowing types to be tagged, it's possible to create "guardrails" against missing tags. As noted above, if a type is tagged but a field emitting that type is not, it fails that contract's composition. If the owning service can ensure that all types are tagged appropriately with the correct tag to filter, it can be a good way to still accomplish the exclude approach while getting some of the benefit of the include approach.
On the other hand, this does mean that a strong governance program must be in place. Otherwise, you can encounter the same issues as discussed in the exclude approach.
You can also use this strategy for documentation purposes, where including and omitting is much less of a concern compared to a realized API. Because a contract is just a graph variant, it's possible to link users to a contract's documentation, making it useful in this context as well.
Cross-subgraph tagging
When considering the strategies described above, there's an additional composition behavior to be aware of: it's possible for a tag applied in one subgraph to affect fields that are defined in another subgraph.
As an example, consider these two subgraphs:
# Subgraph Atype Document @key(fields: "id") {id: ID!name: Stringcontent: String}# Subgraph Btype Document @key(fields: "id") @tag(name: "internal") {id: ID!reviewedBy: AdminUserapprovedBy: AdminUsercontentScore: Float}
As shown above, the Document
type is tagged as internal
by Subgraph B, but not by Subgraph A. When the contract schema is generated for this supergraph, the Document
type definition looks like this:
type Document @key(fields: "id") @tag(name: "internal") {id: ID!name: Stringcontent: StringreviewedBy: AdminUserapprovedBy: AdminUsercontentScore: Float}
This is due to the fact that tag inheritance is applied after composition. Any tags added at the type level are inherited after cross-subgraph entity definitions are merged. This means that what might be considered the "correct" application of a tag within one subgraph can adversely affect other subgraphs and the resulting supergraph.