diff --git a/website/src/docs/docs.json b/website/src/docs/docs.json index 8fbadf4b6ca..fe8fd9033d0 100644 --- a/website/src/docs/docs.json +++ b/website/src/docs/docs.json @@ -194,7 +194,7 @@ }, { "path": "field-ownership-and-sharing", - "title": "Field Ownership and Sharing" + "title": "Field Ownership" }, { "path": "composition", diff --git a/website/src/docs/fusion/v16/adding-a-subgraph.md b/website/src/docs/fusion/v16/adding-a-subgraph.md index a026582d540..f2e00891c54 100644 --- a/website/src/docs/fusion/v16/adding-a-subgraph.md +++ b/website/src/docs/fusion/v16/adding-a-subgraph.md @@ -326,7 +326,7 @@ If composition fails after adding your new subgraph, the error messages point to ### Duplicate field without sharing -**"Field X is defined in multiple subgraphs"**. Your new subgraph defines a field that already exists in another subgraph. Key fields (like `id`) are automatically shareable, but all other duplicated fields need `@shareable` on every definition. See [Field Ownership and Sharing](/docs/fusion/v16/field-ownership-and-sharing) for details. +**"Field X is defined in multiple subgraphs"**. Your new subgraph defines a field that already exists in another subgraph. Key fields (like `id`) are automatically shareable, but all other duplicated fields need `@shareable` on every definition. See [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing) for details. ### Missing lookup diff --git a/website/src/docs/fusion/v16/attribute-and-directive-reference.md b/website/src/docs/fusion/v16/attribute-and-directive-reference.md index c1c1cedfd5c..de39e74cb21 100644 --- a/website/src/docs/fusion/v16/attribute-and-directive-reference.md +++ b/website/src/docs/fusion/v16/attribute-and-directive-reference.md @@ -6,28 +6,28 @@ Quick reference for all Fusion-related attributes and their GraphQL directive eq # Attribute and Directive Reference Table -| Attribute | Directive | Description | Guide Page | -| --------------------------- | --------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `[ObjectType]` | — | Maps static class as extension to entity type T | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[QueryType]` | — | Marks class as contributing Query root fields | [Getting Started](/docs/fusion/v16/getting-started) | -| `[MutationType]` | — | Marks class as contributing Mutation root fields | [Getting Started](/docs/fusion/v16/getting-started) | -| `[SubscriptionType]` | — | Marks class as contributing Subscription root fields | [Getting Started](/docs/fusion/v16/getting-started) | -| `[Lookup]` | `@lookup` | Declares field as entity lookup resolver | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[NodeResolver]` | — | Marks as Relay node resolver | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[Internal]` | `@internal` | Hides lookup from composed schema | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[Shareable]` | `@shareable` | Allows multiple subgraphs to resolve field | [Field Ownership and Sharing](/docs/fusion/v16/field-ownership-and-sharing) | -| `[Parent(requires: "...")]` | — | Declares field requirements from parent | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[Require("...")]` | `@require` | Declares complex field requirements | [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping), [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) | -| `[EntityKey("...")]` | `@key` | Declares entity key for resolution | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[BindMember(nameof(...))]` | — | Replaces raw FK with resolved entity | [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) | -| `[Tag("...")]` | `@tag` | Applies tag for composition filtering | [Composition](/docs/fusion/v16/composition) | -| `[DataLoader]` | — | Source-generates DataLoader interface | [Getting Started](/docs/fusion/v16/getting-started), [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[UsePaging]` | — | Enables cursor-based pagination | [Getting Started](/docs/fusion/v16/getting-started) | -| `[ID]` | — | Declares field as Relay-style ID | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | -| `[Inaccessible]` | `@inaccessible` | Hides from composite schema | [Composition](/docs/fusion/v16/composition) | -| `[Override(from: "...")]` | `@override` | Migrates field ownership | [Deployment and CI/CD](/docs/fusion/v16/deployment-and-ci-cd) | -| `[Provides("...")]` | `@provides` | Declares locally-resolvable subfields | [Field Ownership and Sharing](/docs/fusion/v16/field-ownership-and-sharing), [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) | -| `[External]` | `@external` | Field defined by another subgraph | [Field Ownership and Sharing](/docs/fusion/v16/field-ownership-and-sharing), [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) | +| Attribute | Directive | Description | Guide Page | +| --------------------------- | --------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `[ObjectType]` | — | Maps static class as extension to entity type T | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[QueryType]` | — | Marks class as contributing Query root fields | [Getting Started](/docs/fusion/v16/getting-started) | +| `[MutationType]` | — | Marks class as contributing Mutation root fields | [Getting Started](/docs/fusion/v16/getting-started) | +| `[SubscriptionType]` | — | Marks class as contributing Subscription root fields | [Getting Started](/docs/fusion/v16/getting-started) | +| `[Lookup]` | `@lookup` | Declares field as entity lookup resolver | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[NodeResolver]` | — | Marks as Relay node resolver | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[Internal]` | `@internal` | Hides lookup from composed schema | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[Shareable]` | `@shareable` | Allows multiple subgraphs to resolve field | [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing) | +| `[Parent(requires: "...")]` | — | Declares field requirements from parent | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[Require("...")]` | `@require` | Declares complex field requirements | [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping), [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) | +| `[EntityKey("...")]` | `@key` | Declares entity key for resolution | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[BindMember(nameof(...))]` | — | Replaces raw FK with resolved entity | [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) | +| `[Tag("...")]` | `@tag` | Applies tag for composition filtering | [Composition](/docs/fusion/v16/composition) | +| `[DataLoader]` | — | Source-generates DataLoader interface | [Getting Started](/docs/fusion/v16/getting-started), [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[UsePaging]` | — | Enables cursor-based pagination | [Getting Started](/docs/fusion/v16/getting-started) | +| `[ID]` | — | Declares field as Relay-style ID | [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) | +| `[Inaccessible]` | `@inaccessible` | Hides from composite schema | [Composition](/docs/fusion/v16/composition) | +| `[Override(from: "...")]` | `@override` | Migrates field ownership | [Deployment and CI/CD](/docs/fusion/v16/deployment-and-ci-cd) | +| `[Provides("...")]` | `@provides` | Declares locally-resolvable subfields | [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing), [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) | +| `[External]` | `@external` | Field defined by another subgraph | [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing), [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) | # See Also diff --git a/website/src/docs/fusion/v16/data-requirements-and-mapping.md b/website/src/docs/fusion/v16/data-requirements-and-mapping.md index 8c80bb55ca7..10b2159178e 100644 --- a/website/src/docs/fusion/v16/data-requirements-and-mapping.md +++ b/website/src/docs/fusion/v16/data-requirements-and-mapping.md @@ -389,5 +389,5 @@ If a `@require` argument appears in the composite schema when it should not, che ## Next Steps - **Need entity identity and lookup patterns?** See [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) for the full guide to keys, public vs. internal lookups, and composite keys. -- **Need field ownership contracts and sharing semantics?** See [Field Ownership and Sharing](/docs/fusion/v16/field-ownership-and-sharing). +- **Need field ownership contracts?** See [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing). - **Need the directive and attribute quick reference?** See the [Attribute and Directive Reference](/docs/fusion/v16/attribute-and-directive-reference). diff --git a/website/src/docs/fusion/v16/entities-and-lookups.md b/website/src/docs/fusion/v16/entities-and-lookups.md index 4bb0245151d..fc7fa73aae0 100644 --- a/website/src/docs/fusion/v16/entities-and-lookups.md +++ b/website/src/docs/fusion/v16/entities-and-lookups.md @@ -407,7 +407,7 @@ builder ## Next Steps -- **Need field ownership and sharing contracts?** See [Field Ownership and Sharing](/docs/fusion/v16/field-ownership-and-sharing). +- **Need field ownership contracts?** See [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing). - **Need argument mapping and cross-subgraph dependencies?** See [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) for `@is`, `@require`, and FieldSelectionMap patterns. - **Need runtime performance guidance?** See Hot Chocolate docs for DataLoader and batching patterns used inside lookup resolvers. - **Ready to go to production?** See [Authentication and Authorization](/docs/fusion/v16/authentication-and-authorization) for securing your gateway and subgraphs, or [Deployment and CI/CD](/docs/fusion/v16/deployment-and-ci-cd) for setting up independent subgraph deployments. diff --git a/website/src/docs/fusion/v16/field-ownership-and-sharing.md b/website/src/docs/fusion/v16/field-ownership-and-sharing.md index badab5491e4..4ca4df65db9 100644 --- a/website/src/docs/fusion/v16/field-ownership-and-sharing.md +++ b/website/src/docs/fusion/v16/field-ownership-and-sharing.md @@ -1,5 +1,5 @@ --- -title: "Field Ownership and Sharing" +title: "Field Ownership" --- Field ownership defines which subgraph is responsible for each field in the composite schema. Clear ownership boundaries keep composition predictable and prevent semantic drift across teams. diff --git a/website/src/docs/fusion/v16/schema-exposure-and-evolution.md b/website/src/docs/fusion/v16/schema-exposure-and-evolution.md new file mode 100644 index 00000000000..739eac9093a --- /dev/null +++ b/website/src/docs/fusion/v16/schema-exposure-and-evolution.md @@ -0,0 +1,402 @@ +--- +title: "Schema Exposure and Evolution" +--- + +Not everything in your source schema should be visible to clients, and not everything should stay the same forever. As your distributed graph grows, you need control over two things: what clients can see today, and how the schema changes over time. + +This page covers five directives that handle exposure and evolution. `@inaccessible` and `@internal` control visibility in the composite schema. `@deprecated` and `@requiresOptIn` manage the lifecycle of fields and values. `@override` migrates field ownership between subgraphs. If you have completed the [Getting Started](/docs/fusion/v16/getting-started) tutorial and worked through [Entities and Lookups](/docs/fusion/v16/entities-and-lookups), you already used `@internal` on lookup fields. Here, you will see the full picture of visibility control and schema evolution. + +## Controlling Client Visibility + +Your source schemas contain fields and types that serve different audiences. Some are for clients, some carry internal data shared between subgraphs, and some are infrastructure that only the gateway uses. Fusion provides two directives for hiding schema elements from the composite schema. They differ in how they interact with composition merging. + +### Hidden Fields + +Mark a field or type `@inaccessible` to hide it from the public client-facing composite schema while keeping it available for internal. The element still participates in composition merging and can be referenced by `@require` dependencies in other subgraphs. + +**GraphQL schema** + +```graphql +type Product @key(fields: "id") { + id: ID! + name: String! + price: Float! + internalSkuCode: Int! @inaccessible +} +``` + +**C# declaration** + +```csharp +[EntityKey("id")] +public class Product +{ + public int Id { get; set; } + + public required string Name { get; set; } + + public double Price { get; set; } + + [Inaccessible] + public int InternalSkuCode { get; set; } +} +``` + +Clients cannot query `internalSkuCode`. But other subgraphs can depend on it through `[Require]`. For example, a Warehouse subgraph could require the SKU code for inventory lookups without exposing it to clients. + +Apart from `@require` inaccessible fields can also be used as lookups or as keys. + +You can apply `@inaccessible` to fields, types, arguments, enum values, input fields, scalars, interfaces, and unions. Any schema element that can appear in the composite schema can be hidden. + +**Enum values** + +Hiding individual enum values is useful when different subgraphs define the same enum with slightly different values. Mark the values that should not be in the composite schema as `@inaccessible` to resolve merge conflicts. + +```graphql +enum OrderStatus { + PENDING + SHIPPED + DELIVERED + CANCELLED @inaccessible +} +``` + +The `CANCELLED` value does not appear in the composite schema. Subgraphs can still return it internally, but clients never see it. + +**Constraint:** You cannot mark a required input field as `@inaccessible`. If a client must provide a value, they need to see the field. Composition fails if you try. + +### Internal Lookups + +The `@internal` directive is designed for lookups. An internal lookup is a query field that the gateway uses for entity resolution but that clients cannot call. Internal lookups do not participate in composition merging, which means multiple subgraphs can define lookups with the same field name and different argument shapes without causing a conflict. This gives each subgraph the flexibility to resolve an entity in whatever way makes sense for its data, without coordinating field signatures across teams. + +**GraphQL schema** + +```graphql +type Query { + productById(id: ID!): Product @lookup @internal +} +``` + +**C# resolver** + +```csharp +[QueryType] +public static partial class ProductQueries +{ + [Internal, Lookup] + public static Product? GetProductById(int id) + => new(id); +} +``` + +Without `[Internal]`, this lookup would appear in the composite schema as a second `productById` query field, conflicting with the Products subgraph's public lookup. With `[Internal]`, the gateway can still use it for entity resolution, but clients never see it. + +You can also group internal lookups under a dedicated root object to keep routing infrastructure in one place. + +**GraphQL schema (grouped internal lookups)** + +```graphql +type Query { + internalLookups: InternalLookups @internal +} + +type InternalLookups @internal { + productByTenantAndSku(tenantId: ID!, sku: String!): Product @lookup +} +``` + +**C# declaration** + +```csharp +// Reviews/Types/InternalLookups.cs + +[QueryType] +public static partial class Query +{ + [Internal] + public static InternalLookups GetInternalLookups { get; } = new(); +} + +[Internal, ObjectType] +public partial class InternalLookups +{ + [Lookup] + public Product? GetProductByTenantAndSku(int tenantId, string sku) + => ProductRepository.GetByTenantAndSku(tenantId, sku); +} +``` + +For a deeper look at internal vs. public lookups, composite keys, and the node pattern, see [Entities and Lookups](/docs/fusion/v16/entities-and-lookups). + +### Choosing Between Hidden and Internal + +These directives serve different purposes. `@inaccessible` hides data from clients while keeping it available across subgraphs. `@internal` keeps lookups local to one subgraph so they can vary freely without merge conflicts. + +| Behavior | `@inaccessible` | `@internal` | +| --------------------------------- | -------------------------------------- | ----------------------------------- | +| Visible to clients | No | No | +| Participates in merging | Yes | No | +| Can conflict across subgraphs | Yes (types must be compatible) | No | +| Usable in `@require` dependencies | Yes | No | +| Primary use case | Internal data shared between subgraphs | Lookup entry points for the gateway | + +Use `@inaccessible` when the field carries data that other subgraphs need but clients should not see. Use `@internal` on lookups that exist only for gateway entity resolution. + +## Deprecating Fields and Values + +The `@deprecated` directive signals that a field, argument, or enum value is being phased out. Clients see the deprecation reason in introspection, and GraphQL tooling (IDEs, linters, code generators) can warn consumers to migrate away. The field continues to work. Deprecation is a soft signal, not a hard removal. + +**GraphQL schema** + +```graphql +type Query { + product(id: ID!): Product + @lookup + @deprecated(reason: "Use `productById` instead.") + productBySku(sku: String!): Product @lookup +} +``` + +**C# resolver** + +```csharp +// Products/Types/ProductQueries.cs + +[QueryType] +public static partial class ProductQueries +{ + [GraphQLDeprecated("Use `productBySku` instead.")] + [Lookup] + public static async Task GetProductAsync( + int id, + IProductByIdDataLoader productById, + CancellationToken cancellationToken) + => await productById.LoadAsync(id, cancellationToken); + + [Lookup] + public static async Task GetProductBySkuAsync( + string sku, + IProductBySkuDataLoader productBySku, + CancellationToken cancellationToken) + => await productBySku.LoadAsync(id, cancellationToken); +} +``` + +You can also use .NET's built-in `[Obsolete]` attribute. Hot Chocolate treats it the same as `[GraphQLDeprecated]`. + +```csharp +[Obsolete("Use `productById` instead.")] +[Lookup] +public static async Task GetProductAsync(...) + => ...; +``` + +Deprecation applies to output fields, input fields, arguments, and enum values. + +**Enum value deprecation** + +```graphql +enum SortOrder { + ASC + DESC + RELEVANCE @deprecated(reason: "Use full-text search instead.") +} +``` + +**Constraint:** You cannot deprecate a non-null argument or input field without a default value. If clients must provide a value, they cannot stop using the field. + +### Deprecation Across Subgraphs + +If a shareable field is deprecated in at least one subgraph, it is deprecated in the composite schema. You do not need to deprecate it in every subgraph that defines it. With shared ownership comes the power for any owner to deprecate the field for all clients. + +If you only want to remove a shared field from one subgraph, you do not need to deprecate it. Remove the field from that subgraph and the gateway will resolve it from the remaining subgraphs that still provide it. + +## Experimental and Preview Features + +The `@requiresOptIn` directive is the counterpart to `@deprecated`. Where `@deprecated` signals that a field is going away, `@requiresOptIn` signals that a field is not yet stable. Fields marked with `@requiresOptIn` are hidden from introspection by default. Clients must explicitly opt in to discover and use them. + +This is useful for rolling out experimental features, expensive operations, or anything where the consumer should make a conscious decision before using it. + +**GraphQL schema** + +```graphql +type Product { + id: ID! + name: String! + price: Float! + dynamicPrice: Decimal @requiresOptIn(feature: "experimentalPricing") +} +``` + +**C# declaration** + +```csharp +// Products/Types/Product.cs + +public class Product +{ + public int Id { get; set; } + + public required string Name { get; set; } + + public double Price { get; set; } + + [RequiresOptIn("experimentalPricing")] + public decimal? DynamicPrice { get; set; } +} +``` + +The `dynamicPrice` field does not appear in standard introspection results. Clients must opt in to see it. + +The directive is repeatable. A single field can be part of multiple features. + +```csharp +[RequiresOptIn("experimentalPricing")] +[RequiresOptIn("betaApi")] +public decimal? DynamicPrice { get; set; } +``` + +### Enabling Opt-In Support + +Opt-in features are disabled by default. Enable them in your schema configuration. + +**C# configuration** + +```csharp +// Products/Program.cs + +builder + .AddGraphQL("Products") + .AddTypes() + .ModifyOptions(o => o.EnableOptInFeatures = true); +``` + +### Discovering Opt-In Fields + +Clients pass the `includeOptIn` argument in introspection queries to discover opt-in fields. + +```graphql +{ + __type(name: "Product") { + fields(includeOptIn: ["experimentalPricing"]) { + name + requiresOptIn + } + } +} +``` + +The `includeOptIn` argument is available on `fields`, `args`, `inputFields`, and `enumValues` in introspection queries. + +To discover which opt-in features exist in the schema: + +```graphql +{ + __schema { + optInFeatures + } +} +``` + +### Feature Stability Levels + +You can declare the stability level of each opt-in feature at the schema level. This lets consumers know whether a feature is experimental, preview, or any other stability level you define. + +**C# configuration** + +```csharp +// Products/Program.cs + +builder + .AddGraphQL("Products") + .AddTypes() + .ModifyOptions(o => o.EnableOptInFeatures = true) + .OptInFeatureStability("experimentalPricing", "experimental"); +``` + +Consumers can query feature stability through introspection: + +```graphql +{ + __schema { + optInFeatureStability { + feature + stability + } + } +} +``` + +### Constraints + +Like `@deprecated`, you cannot apply `@requiresOptIn` to non-null arguments or input fields without a default value. Hiding a required field would break queries that need to provide it. + +### Opt-In Across Subgraphs + +If a shareable field is marked `@requiresOptIn` in at least one subgraph, it requires opt-in in the composite schema. To make the field generally available again, every subgraph that defines it must remove the `@requiresOptIn` directive. This is the inverse of `@deprecated`, where a single subgraph can deprecate a field for all clients. With `@requiresOptIn`, a single subgraph can gate a shared field behind opt-in, and it stays gated until all owners agree to remove the restriction. + +## Migrating Field Ownership Between Subgraphs + +As your system evolves, you may need to move a field from one subgraph to another. A team might split a subgraph, or a field might belong more naturally in a different domain. The `@override` directive migrates field ownership without breaking existing queries. + +When you apply `[Override(from: "source-subgraph")]`, the gateway routes requests for that field to the new subgraph instead of the original. The old subgraph's resolver is no longer called. No client-facing changes are needed. + +**Before: Products subgraph owns the reviews field** + +```csharp +[ObjectType] +public static partial class ProductNode +{ + public static async Task> GetReviewsAsync( + [Parent] Product product, + ReviewService reviewService) + => await reviewService.GetReviewsByProductIdAsync(product.Id); +} +``` + +**After: Reviews subgraph takes ownership** + +```csharp +[ObjectType] +public static partial class ProductNode +{ + [Override(from: "products-api")] + public static async Task> GetReviewsAsync( + [Parent] Product product, + PagingArguments args, + IReviewsByProductIdDataLoader loader, + CancellationToken ct) + => await loader + .With(args) + .LoadAsync(product.Id, ct) + .ToConnectionAsync(); +} +``` + +**GraphQL schema** + +```graphql +# Reviews subgraph +type Product { + id: ID! + reviews: [Review!]! @override(from: "products-api") +} +``` + +The `from` argument is the subgraph name (from `schema-settings.json`) that originally owned the field. + +### Migration Workflow + +1. Add the field to the new subgraph with `[Override(from: "old-subgraph")]`. +2. Export schemas and compose. Composition validates that the override is valid. +3. Deploy the new subgraph. The gateway routes the field to it. +4. Remove the old resolver from the original subgraph when ready. + +The old resolver stays in place during the transition. Both subgraphs can define the field simultaneously because `[Override]` tells composition which one wins. This avoids duplicate-field errors without requiring `[Shareable]`. + +## Next Steps + +- **Need entity resolution patterns?** See [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) for public vs. internal lookups, composite keys, and the node pattern. +- **Need cross-subgraph field dependencies?** See [Data Requirements](/docs/fusion/v16/data-requirements-and-mapping) for `@require`, `@is`, and FieldSelectionMap patterns. +- **Need field sharing and ownership rules?** See [Field Ownership](/docs/fusion/v16/field-ownership-and-sharing) for `@shareable`, `@external`, and `@provides` patterns. +- **Adding a new subgraph?** See [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) for the full walkthrough of creating and composing a new subgraph.