Using @tag and @inaccessible to Build Contract Variants
@tag and @inaccessible look like a pair of similar directives for hiding things, and treating them that way is the fastest route to a broken contract. This page shows exactly what each one does to a composed schema, how a contract filter reads them, and the tagging discipline that keeps a filtered variant composable as the graph grows. It sits under Contracts and Schema Variants for API Consumers.
When to use this pattern
- Use
@tagwhen different audiences should see different subsets of the same graph, and the number of audiences is small and stable — a public API, a partner API, an internal API. - Use
@inaccessiblewhen a schema element must exist for composition but must never appear in any API schema, such as a key field a subgraph needs but nobody should query, or a field being introduced before its consumers are ready. - Skip both when the difference is per caller. A field one customer may see and another may not is an authorization concern, handled by directive patterns for cross-service authorization, not by a schema filter.
Prerequisites
The two directives are not alternatives
@tag is metadata and nothing else. Adding it changes no behaviour whatsoever: the field still composes, still appears in every API schema, and still resolves exactly as before. The tag is recorded in the supergraph so that something else — a contract filter — can act on it later. A graph with tags and no contracts behaves identically to a graph with no tags at all.
@inaccessible is behaviour. It removes the element from the API schema of the supergraph, in every variant, unconditionally. The element remains in the subgraph schema and remains usable by the router internally, which is precisely why it exists: a field can participate in a @key or a @requires while being invisible to clients.
The consequence is that the two operate at different times and answer different questions. @tag answers “which audiences care about this?” and defers the decision. @inaccessible answers “should anyone be able to query this?” and settles it immediately.
Implementation walkthrough
Everything below lives in one subgraph and needs no coordination beyond agreeing the tag names. Import both directives, then tag transitively: the type, the fields that audience needs, and the types those fields return.
# catalog/schema.graphql
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@tag", "@inaccessible", "@shareable"])
type Query {
# Root fields need tagging too, or the audience has no entry point.
product(id: ID!): Product @tag(name: "public") @tag(name: "partner")
}
type Product @key(fields: "id") @tag(name: "public") @tag(name: "partner") {
id: ID! @tag(name: "public") @tag(name: "partner")
name: String! @tag(name: "public") @tag(name: "partner")
# Money is returned by a tagged field, so Money must be tagged as well.
price: Money! @tag(name: "public") @tag(name: "partner")
wholesaleCost: Money! @tag(name: "partner")
# Needed by the pricing subgraph's @requires; never queryable by anyone.
internalMargin: Float! @inaccessible
}
type Money @tag(name: "public") @tag(name: "partner") {
amount: Int! @tag(name: "public") @tag(name: "partner")
currencyCode: String! @tag(name: "public") @tag(name: "partner")
}
enum ProductStatus @tag(name: "public") @tag(name: "partner") {
ACTIVE @tag(name: "public") @tag(name: "partner")
DISCONTINUED @tag(name: "public") @tag(name: "partner")
# An internal-only state that partners must not learn about.
EMBARGOED @inaccessible
}
Three details in that snippet do real work. Tagging Query.product matters because a contract with no reachable root field is a schema nobody can query, and composition will happily produce it. Tagging Money matters because price returns it, and a retained field returning a filtered type is a composition error. Tagging the individual enum values matters because enum values are filterable elements in their own right — leaving EMBARGOED accessible in a partner contract leaks a business state through nothing more than introspection.
The repetition is real and it is the main practical complaint about tagging. Two mitigations help. Generate the tags where the audience is derivable from a naming convention or a directive you already apply, and lint for the transitive rule so a missing tag on a returned type fails locally rather than in contract composition.
Verification steps
Confirm the subgraph still composes with the directives present:
rover subgraph check my-graph@current \
--name catalog --schema ./catalog/schema.graphql
Then compose the contract and compare its API schema against what you intended:
rover contract publish my-graph@public \
--source-variant my-graph@current --include-tag public
rover graph fetch my-graph@public > public.graphql
grep -c "wholesaleCost\|internalMargin\|EMBARGOED" public.graphql # expect 0
grep -c "type Money" public.graphql # expect 1
The second grep is the one that catches transitive mistakes. A contract missing an internal field is what you asked for; a contract missing Money means price went with it, and the API you just published is quietly smaller than you think.
Finally, query the contract router for a field that should not exist and confirm the error is a validation error rather than an authorization error:
query { product(id: "P-1") { wholesaleCost { amount } } }
# expected: Cannot query field "wholesaleCost" on type "Product"
That exact message is the signal that filtering worked. An authorization-style denial would mean the field is still in the schema and something else rejected it, which is a different — and weaker — guarantee.
One habit is worth adopting before the first contract exists: write the tag vocabulary down somewhere every subgraph team reads, with one sentence per tag describing the audience it names. Tags are strings compared exactly, they are referenced by filters nobody in a given repository can see, and a typo produces a silently smaller API rather than an error. A three-line document and a lint rule that rejects unknown tag names removes an entire category of incident for effectively no cost, and it also makes the review question concrete — instead of “is this field sensitive?”, the reviewer asks “which of these four audiences needs it?”, which is a question with a checkable answer.
Before tagging a large schema it is worth knowing which kinds of element a filter can act on at all, because that determines how carefully you have to read each type. Object types, fields, enum values and arguments are all independently filterable — more surface than most people expect — while built-in scalars never are. The argument row is the one that causes quiet breakage, since removing an argument leaves the field in place and changes its signature underneath callers who were passing it.
Common mistakes & gotchas
Tagging the field but not the type. A field survives the filter only if its parent type does. Tagging Product.name while leaving type Product untagged removes both, and the resulting contract is missing an entire type for a reason that reads as unrelated.
Assuming @inaccessible hides data. It removes an element from the API schema of the supergraph. The subgraph still declares and serves it, so a caller who can reach the subgraph directly is unaffected. Network isolation of subgraphs is what makes @inaccessible a boundary rather than a convention.
Forgetting enum values and arguments. Both are independently filterable. An untagged argument on a retained field disappears from the contract, which changes the field’s signature for that audience — usually breaking whoever was passing it.
Frequently Asked Questions
Can one element carry several tags?
Yes, and it is the normal case. @tag(name: "public") @tag(name: "partner") marks a field for two audiences, and a filter that includes either tag keeps it. There is no ordering or precedence between tags on the same element; membership is a simple union.
Does an exclude tag beat an include tag?
Always. If an element carries both an included and an excluded tag, it is removed. That precedence makes exclusion useful as a safety net: tag anything experimental with a single internal-only tag, exclude that tag from every contract, and no include-list mistake can leak it.
Should key fields be tagged?
They do not need to be — a surviving entity keeps its key fields automatically, because the router cannot resolve the entity without them. Tagging them anyway is harmless and some teams do it for consistency. What you must not do is mark a key field @inaccessible and expect the entity to still resolve across subgraph boundaries in that variant.
Can I introduce a field to the graph before any audience should see it?
Yes, and this is one of the better uses of @inaccessible. Ship the field marked inaccessible so it composes and can be wired up and tested internally, then swap the directive for the appropriate tags when the audience is ready. Composition is exercised the whole time, so the enabling change is a one-line edit rather than a new field landing untested.
How do I audit what a contract actually exposes?
Fetch the variant’s API schema and read it, rather than reasoning about the filter. rover graph fetch my-graph@public returns exactly what that audience sees, which is the only artifact that reflects every tag, every exclusion, and every transitive removal at once. Diff that file between releases in CI and you have a reviewable record of your public surface changing over time — far more useful than reviewing tag diffs across a dozen subgraph repositories.
Related
- Contracts and Schema Variants for API Consumers — parent guide
- Publishing a Public API Variant with Apollo Contracts — the publish and rollout workflow
- Hiding Internal Fields from External Consumers — picking the right mechanism per field
- Resolving Schema Conflicts in Apollo Federation —
@inaccessibleas a conflict resolution tool - Directive Patterns for Cross-Service Authorization — when the difference is per caller