Docs
Try Apollo Studio

Contracts usage patterns

contractsbest practices

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: String
supportNotes: [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.
  1. 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: String
support: 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 A
type Document @key(fields: "id") {
id: ID!
name: String
content: String
}
# Subgraph B
type Document @key(fields: "id") @tag(name: "internal") {
id: ID!
reviewedBy: AdminUser
approvedBy: AdminUser
contentScore: 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: String
content: String
reviewedBy: AdminUser
approvedBy: AdminUser
contentScore: 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.

Edit on GitHub
Next
Home