diff --git a/website/src/docs/fusion/v16/entities-and-lookups.md b/website/src/docs/fusion/v16/entities-and-lookups.md index 0533a583449..624de302956 100644 --- a/website/src/docs/fusion/v16/entities-and-lookups.md +++ b/website/src/docs/fusion/v16/entities-and-lookups.md @@ -2,64 +2,72 @@ title: "Entities and Lookups" --- -# Entities and Lookups +Entities are the mechanism that makes distributed GraphQL work. They are types with stable keys that can be referenced and resolved across subgraphs. For example, the Products subgraph defines the `Product` type, and the Reviews subgraph contributes the `reviews` field to `Product`. The Accounts subgraph defines the `User` type, and other subgraphs can contribute additional fields to `User`. Without entities, each subgraph would be an isolated API. With entities, those subgraphs compose into one unified API. -Entities are the mechanism that makes distributed GraphQL work. They are types that can be uniquely identified and resolved across multiple subgraphs -- the `Product` that the Products subgraph defines and the Reviews subgraph extends, the `User` that the Accounts subgraph owns and the Reviews subgraph adds reviews to. Without entities, each subgraph would be an isolated API. With them, you get one unified graph. - -This page covers entity resolution in depth: how entities work, how lookups enable cross-subgraph resolution, how field ownership works, and how to optimize entity fetching. If you completed the [Getting Started](/docs/fusion/v16/getting-started) tutorial, you already used entities and lookups. This page goes deeper. +This page explains entity resolution in more detail: how entities are defined and how lookups resolve them across subgraphs. If you completed the [Getting Started](/docs/fusion/v16/getting-started) tutorial, you already used these concepts. Here, you will focus on the mechanics and patterns behind them. ## What Makes a Type an Entity -A regular GraphQL type lives in one subgraph and is resolved entirely by that subgraph. An entity is different -- it appears in multiple subgraphs, each contributing different fields. - -For a type to work as an entity, two things must be true: +A type is not an entity because it appears in multiple subgraphs. It is an entity because it has stable key-based identity. An entity is a type with one or more key fields that uniquely identify an instance across multiple subgraphs. Those key fields form the contract between subgraphs: one subgraph can return an entity reference by key, and another subgraph can use that key to resolve additional fields for the same instance. -1. **It has key fields** that uniquely identify each instance (like `id` or `sku`). -2. **At least one subgraph provides a lookup** -- a query field that can resolve the entity given its key fields. +In practice, two requirements matter: -In the Products subgraph, `Product` is a full type with `id`, `name`, `price`, and other fields: +1. **Entity identity:** one or more key fields uniquely identify each instance, like `id` or `sku`. +2. **Entity resolution:** at least one lookup is available so the gateway can resolve references by key. -```csharp -// Products/Data/Product.cs +```graphql +# Products subgraph +type Product { + id: ID! + name: String! +} -public class Product -{ - public int Id { get; set; } - public required string Name { get; set; } - public double Price { get; set; } - public int Weight { get; set; } +type Query { + productById(id: ID!): Product @lookup } ``` -In the Reviews subgraph, `Product` is an entity stub -- a lightweight declaration with just the key field and the new fields this subgraph contributes: +Another subgraph can reuse the same key and contribute fields. -```csharp -// Reviews/Types/Product.cs +```graphql +# Reviews subgraph +type Product { + id: ID! + reviews: [Review!]! +} -public sealed record Product([property: ID] int Id) -{ - public List GetReviews() - => ReviewRepository.GetByProductId(Id); +type Query { + productById(id: ID!): Product @lookup @internal } ``` -The entity stub does not duplicate `name`, `price`, or `weight`. It only declares the key (`Id`) and the fields it adds (`reviews`). During composition, Fusion merges these into one `Product` type with all fields. The gateway resolves each field from the subgraph that owns it. - -### Entity Stubs Are Not Code Duplication - -A common concern: "Am I duplicating the Product type across subgraphs?" No. The entity stub is a declaration, not a copy. It says "I know `Product` exists, identified by `Id`, and I want to contribute fields to it." The stub has no knowledge of the other subgraph's fields, does not import the other subgraph's code, and can be deployed independently. +In these examples, `id` is the key and `@lookup` defines how `Product` is resolved by that key. The Reviews lookup is internal, so clients cannot call it directly, but the gateway can use it to enter the Reviews subgraph and resolve `reviews`. ## Lookups -A lookup is a query field that resolves an entity by its key. The gateway uses lookups to fetch entities from the subgraph that owns them. Without a lookup, the gateway has no way to enter a subgraph and resolve an entity. +A lookup is a query field that resolves an entity by its key. The gateway uses lookups to fetch additional fields for entities. Depending on the requested fields and available routes, it can use any subgraph that provides those fields and a compatible lookup path. Without a lookup, the gateway has no way to enter a subgraph and resolve an entity. ### Public Lookups A public lookup serves two purposes: clients can call it directly as a query field, and the gateway uses it for entity resolution behind the scenes. -```csharp -// Products/Types/ProductQueries.cs +**GraphQL schema** + +```graphql +type Query { + productById(id: ID!): Product @lookup +} +``` + +The `@lookup` directive marks `productById` as a lookup for `Product`. Because the argument is named `id`, composition maps it to the `Product.id` key field. Cross-subgraph transitions depend on this mapping: the source subgraph must provide the key, and the target subgraph must expose a lookup that accepts that key. + +Lookups must return nullable entity types. In GraphQL, that means `Product` instead of `Product!`. In C#, use a nullable return type like `Product?`. This allows unresolved keys to return `null` and helps avoid cascading failures when one or more subgraphs cannot provide additional fields for an entity. +In Hot Chocolate, the `@lookup` directive is represented by the `[Lookup]` attribute. + +**C# resolver** + +```csharp [QueryType] public static partial class ProductQueries { @@ -72,57 +80,128 @@ public static partial class ProductQueries } ``` -The `[Lookup]` attribute tells Fusion that `GetProductByIdAsync` resolves a `Product` entity by its `id` argument. The composition engine infers from this that `id` is a key field for `Product`. +If the argument name does not match a field name on the type, the `@is` directive must be used to map the argument to the key field. -Public lookups return nullable types (`Product?`). If a client passes an ID that does not exist, the lookup returns `null`. This is correct because clients can call the lookup directly with arbitrary IDs. +```graphql +type Query { + product(productId: ID! @is(field: "id")): Product @lookup +} +``` + +In Hot Chocolate, you can use the `[Is]` attribute with `nameof`. + +```csharp +[QueryType] +public static partial class ProductQueries +{ + [Lookup] + public static async Task GetProductAsync( + [Is(nameof(Product.Id))] int productId, + IProductByIdDataLoader productById, + CancellationToken cancellationToken) + => await productById.LoadAsync(productId, cancellationToken); +} +``` ### Internal Lookups -An internal lookup is hidden from the composite schema. Clients cannot call it. It exists only for the gateway to use during entity resolution. +An internal lookup is hidden from the composite schema. Clients cannot call it directly. It exists only for the gateway to use during entity resolution. -```csharp -// Reviews/Types/ProductQueries.cs +**GraphQL schema** +```graphql +type Query { + productById(id: ID!): Product @internal @lookup +} +``` + +The `@internal` directive tells the composition to exclude this lookup from the public composite schema. The gateway can still use it when it needs to enter the Reviews subgraph to resolve `Product.reviews`, but clients never see or call it. + +**C# resolver** + +```csharp [QueryType] public static partial class ProductQueries { [Lookup, Internal] - public static Product GetProductById([ID] int id) + public static Product? GetProductById([ID] int id) => new(id); } ``` -The `[Internal]` attribute hides this lookup from the composite schema. The gateway uses it when it needs to enter the Reviews subgraph to resolve `Product.reviews`, but clients never see or call it. +You can also group internal lookups under a dedicated internal root field. This keeps internal routing entry points in one place and avoids repeating `@internal` on every lookup field. + +**GraphQL schema (grouped internal lookups)** + +```graphql +type Query { + internalLookups: InternalLookups @internal +} + +type InternalLookups @internal { + productByTenantAndSku(tenantId: ID!, sku: String!): Product @lookup +} +``` + +**C# declaration** + +```csharp +[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); +} +``` -Internal lookups typically construct a stub object from the key without checking whether the entity actually exists (note the non-nullable return type `Product` and the simple `new(id)`). This is safe because the gateway only calls internal lookups during entity resolution, after another subgraph has already confirmed the entity exists. +In this pattern, clients cannot access `internalLookups` from the composite schema, but the gateway can still use nested `@lookup` fields for internal transitions. ### When to Use Internal vs. Public Lookups +![Public vs internal lookup visibility: clients can call public lookups, only the gateway can call internal lookups](../../shared/fusion/entities-public-vs-internal-lookup.png) + Use a **public lookup** when: -- Your subgraph is the primary owner of the entity (the Products subgraph for `Product`, the Accounts subgraph for `User`) +- Your subgraph is the primary owner of the entity. For example, the Products subgraph owns `Product`, and the Accounts subgraph owns `User`. - Clients should be able to query for this entity directly from your subgraph - The lookup validates that the entity exists and returns `null` if it does not Use an **internal lookup** when: -- Your subgraph extends an entity from another subgraph (the Reviews subgraph extending `Product`) +- Your subgraph extends an entity and merely contributes extra fields to it, for example the Reviews subgraph adds the `reviews` field to the `Product` entity. - You do not want clients to enter your subgraph through this lookup -- The lookup just constructs a stub -- it does not need to validate existence +- The lookup just constructs a stub. It does not validate the existence of the entity. -Every entity that your subgraph references must have a lookup in at least one subgraph. If your subgraph extends `Product`, either your subgraph provides a lookup or another subgraph does. The gateway needs at least one lookup per entity to resolve cross-subgraph references. +For cross-subgraph resolution to work, each subgraph that extends an entity by contributing fields must provide a lookup for that entity. For example, if a subgraph extends `Product` but has no `Product` lookup, transitions into that subgraph are unsatisfiable. ### Multiple Lookups Per Entity -An entity can have multiple lookups, even in the same subgraph. This is useful when an entity can be identified by different keys. +An entity can have multiple lookups, even in the same subgraph. This is useful when an entity can be identified by different keys. It is especially helpful when different subgraphs reference the same entity through different keys, for example `User.id` in one place and `User.username` in another. By providing both lookups, the gateway can transition into the target subgraph from either reference shape. -```csharp -// Accounts/Types/UserQueries.cs +**GraphQL schema** + +```graphql +type Query { + userById(id: ID!): User @lookup + userByUsername(username: String!): User @lookup +} +``` +**C# resolver** + +```csharp [QueryType] public static partial class UserQueries { - [Lookup, NodeResolver] + [Lookup] public static async Task GetUserById( int id, IUserByIdDataLoader userById, @@ -138,276 +217,195 @@ public static partial class UserQueries } ``` -The Accounts subgraph provides two lookups for `User`: one by `id` and one by `username`. The gateway can resolve a User reference using whichever key is available. If another subgraph references a User by username (rather than by numeric ID), the gateway uses the `GetUserByUsername` lookup. - -The `[NodeResolver]` attribute on `GetUserById` marks it as the Relay node resolver, enabling `node(id: "...")` queries for this entity. You can have at most one `[NodeResolver]` per entity type per subgraph. - -### Argument Mapping with `@is` +The Accounts subgraph defines two lookups for `User`: one by `id` and one by `username`. The gateway can resolve a User reference using whichever key is available. If another subgraph references a User by username, the gateway uses `GetUserByUsername`. -When a lookup argument name does not match the entity's field name, use the `@is` directive (or its C# equivalent) to map them. For example, if your lookup uses `productId` as the argument name but the entity's key field is `id`: +With more modern GraphQL servers you can also use the finder pattern with the `@oneOf` directive. ```graphql type Query { - personById(productId: ID! @is(field: "id")): Person @lookup + user(by: UserByInput! @is(field: "{ id } | { username }")): User @lookup +} + +input UserByInput @oneOf { + id: ID + username: String } ``` -The `@is` directive tells the composition engine that the `productId` argument corresponds to the `id` field on the `Person` type. When argument names match field names (which is the common case), you can omit `@is` -- the mapping is inferred automatically. +In this case we use the `@is` directive with the choice operator `|` to signal to Fusion that it can use this lookup either with the `id` or the `username` as a key. -### Batch Lookups and the N+1 Problem +### Composite Keys -When the gateway resolves a list of entities (for example, fetching reviews for 10 products), it needs to call the lookup once per entity. Without batching, this creates an N+1 problem -- 1 call to get the product list, then N calls to get reviews for each product. +Some entities are identified by a combination of fields instead of a single field. In that case, the lookup arguments together form the key. -HotChocolate's `[DataLoader]` attribute solves this by automatically batching entity resolution. Instead of N individual calls, the gateway sends one batched request with all N keys. +**GraphQL schema** -```csharp -// Products/Data/ProductDataLoader.cs +```graphql +# Inventory subgraph +type Product { + tenantId: ID! + sku: String! + inStock: Boolean! +} -internal static class ProductDataLoader -{ - [DataLoader] - internal static async Task> GetProductByIdAsync( - IReadOnlyList ids, - ProductContext context, - CancellationToken cancellationToken) - => await context.Products - .Where(t => ids.Contains(t.Id)) - .ToDictionaryAsync(t => t.Id, cancellationToken); +type Query { + productByTenantAndSku(tenantId: ID!, sku: String!): Product @lookup } ``` -The `[DataLoader]` attribute source-generates an `IProductByIdDataLoader` interface. When your lookup uses this DataLoader, the gateway automatically batches entity resolution: +**C# resolver** ```csharp -[Lookup] -public static async Task GetProductByIdAsync( - int id, - IProductByIdDataLoader productById, - CancellationToken cancellationToken) - => await productById.LoadAsync(id, cancellationToken); +[QueryType] +public static partial class ProductQueries +{ + [Lookup] + public static Product? GetProductByTenantAndSku( + int tenantId, + string sku) + => ProductRepository.GetByTenantAndSku(tenantId, sku); +} ``` -Even though the lookup accepts a single `id`, the DataLoader collects all requested IDs and executes a single batch query. This turns N+1 individual database queries into one query with a `WHERE id IN (...)` clause. - -Always use DataLoaders for lookup resolvers that hit a database or external service. Without them, cross-subgraph queries on lists will generate one database query per entity, which degrades performance significantly as the list grows. - -## Field Ownership and `[Shareable]` - -When multiple subgraphs define the same type, Fusion needs to know which subgraph owns each field. The rules are straightforward: +Here, `tenantId` and `sku` are both required to identify `Product`. During planning, Fusion can transition to this lookup only when both key values are available. -### Key Fields Are Automatically Shareable +If the lookup arguments do not match entity field names directly, you can map them with the `@is` directive and even pull up fields. -Fields that serve as entity keys (referenced by lookups) are implicitly shareable. You do not need to add `[Shareable]` to key fields. Both the Products and Reviews subgraphs define `Product.id`, and this composes without conflict because `id` is a key field. +**GraphQL schema with per-argument mapping** -### Non-Key Fields Must Be Unique or Explicitly Shareable - -By default, a non-key field must appear in exactly one subgraph. If two subgraphs define the same non-key field on the same type, composition fails with an error. - -For example, if both the Accounts subgraph and the Reviews subgraph define `User.name`: +```graphql +type Product { + sku: String! + inStock: Boolean! + tenant: Tenant +} -```csharp -// Accounts/Types/UserNode.cs +type Tenant { + id: ID! +} -[ObjectType] -public static partial class UserNode -{ - [Shareable] - public static string GetName([Parent] User user) - => user.Name!; +type Query { + product( + tenantId: ID! @is(field: "tenant.id") + sku: String! @is(field: "sku") + ): Product @lookup } ``` -```csharp -// Reviews/Types/UserNode.cs +**GraphQL schema with input-object mapping** -[ObjectType] -internal static partial class UserNode -{ - [Shareable] - public static string GetName([Parent] User user) - => user.Name!; +```graphql +type Product { + sku: String! + inStock: Boolean! + tenant: Tenant } -``` -Both subgraphs mark `GetName` with `[Shareable]`. This tells Fusion: "this field is intentionally defined in multiple subgraphs, and all definitions return the same data." The gateway can resolve the field from whichever subgraph is most convenient for a given query. +type Tenant { + id: ID! +} -If you forget `[Shareable]` on any definition, composition fails: +input ProductKeyInput { + tenantId: ID! + sku: String! +} -```text -Error: Field "User.name" is defined in subgraphs "accounts-api" and "reviews-api" -without [Shareable]. Mark the field as [Shareable] in all subgraphs that define it, -or remove the duplicate definition. +type Query { + product( + key: ProductKeyInput! @is(field: "{ tenantId: tenant.id, sku }") + ): Product @lookup +} ``` -### When to Use `[Shareable]` - -Mark a field as `[Shareable]` when: - -- Multiple subgraphs genuinely return the same data for this field -- You want the gateway to have the flexibility to resolve it from either subgraph - -Do **not** use `[Shareable]` when: - -- The fields return different data (use different field names instead) -- Only one subgraph should own the field (do not define it in other subgraphs) - -A common use case: the Reviews subgraph stores a local copy of `User.name` for display purposes. Both the Accounts and Reviews subgraphs can resolve it, so both mark it `[Shareable]`. The gateway can resolve `User.name` from whichever subgraph it is already calling for that query, avoiding an extra cross-subgraph hop. - -### The Incorrect "All Types Shareable by Default" Claim +Both variants describe the same composite key. The first maps each argument explicitly. The second maps the input object fields in one selection map. -Some earlier documentation stated that "all object types are shareable by default" in Fusion. This is incorrect. The correct behavior: +> The FieldSelectionMap syntax from the Composite Schemas specification supports more advanced argument-to-field mappings for lookups. For the full grammar and examples, see the [Composite Schemas specification](https://graphql.github.io/composite-schemas-spec/draft/#sec-Appendix-A-Specification-of-FieldSelectionMap-Scalar). -- **Key fields** (referenced by lookups) are automatically shareable -- you do not need `[Shareable]`. -- **All other fields** must be explicitly marked `[Shareable]` if defined in multiple subgraphs. -- Without `[Shareable]`, duplicate non-key fields cause a composition error. +## Explicit Key Declaration -This matches the GraphQL Composite Schemas specification: `@shareable` permits multiple subgraphs to define the same field, and without it, a field may only exist in one subgraph. +In most cases, you do not need to declare entity keys explicitly. The composition engine infers keys from your lookup fields. -## `[EntityKey]` for Explicit Key Declaration - -In most cases, you do not need to declare entity keys explicitly. The composition engine infers keys from your `[Lookup]` resolvers. If `GetProductById(int id)` is marked `[Lookup]`, Fusion infers that `id` is a key field for `Product`. - -Sometimes you need to declare the key explicitly. Use `[EntityKey]` when: +Sometimes you need or want to declare the key explicitly. Use the `@key` directive when: - Your subgraph extends an entity but does not have its own lookup for it -- You want to be explicit about which fields form the key +- You want to be explicit about which fields form the key on the entity itself -```csharp -// Shipping/Types/Product.cs +**GraphQL schema** -[EntityKey("id")] -public sealed record Product([property: ID] int Id) -{ - public int GetDeliveryEstimate( - string zip, - [Require("{ weight }")] int weight) - { - return CalculateShipping(zip, weight); - } +```graphql +type Product @key(fields: "id") { + id: ID! } ``` -The `[EntityKey("id")]` attribute explicitly declares that `Product` is an entity identified by the `id` field. This is needed in the Shipping subgraph because it does not define its own lookup that would let the composition engine infer the key. - -The argument to `[EntityKey]` is the GraphQL field name (lowercase `"id"`), not the C# property name. - -## Optimization Hints: `@provides` and `@external` - -In most cases, you do not need these directives. They are optimization hints that help the gateway avoid unnecessary cross-subgraph calls. - -### `@provides` - -The `@provides` directive (expressed in C# through `[Parent(requires: "...")]` patterns) tells the composition engine that a field resolver can supply certain subfields of its return type locally, avoiding a separate cross-subgraph call. - -For example, if the Reviews subgraph stores a local copy of the user's name alongside each review: +**C# type declaration** ```csharp -// Reviews/Types/ReviewNode.cs - -[ObjectType] -internal static partial class ReviewNode -{ - [BindMember(nameof(Review.AuthorId))] - public static async Task GetAuthorAsync( - [Parent(requires: nameof(Review.AuthorId))] Review review, - IUserByIdDataLoader userById, - CancellationToken cancellationToken) - => await userById.LoadAsync(review.AuthorId, cancellationToken); -} +[EntityKey("id")] +public sealed record Product([property: ID] int Id); ``` -When the Reviews subgraph resolves `Review.author`, it can also supply the author's `name` from its local data. The gateway knows it does not need a separate call to the Accounts subgraph just to get `User.name` -- the Reviews subgraph already has it. - -### `@external` - -The `@external` directive indicates that a field is defined and primarily resolved by another subgraph. It is used in conjunction with `@provides` to mark fields that this subgraph can supply in specific contexts but does not own. - -In HotChocolate, you typically do not need to write `@external` explicitly. The composition engine infers external fields from entity stubs and `@provides` declarations. If you are writing schemas in SDL rather than C#, you would use `@external` on fields that another subgraph owns but that your subgraph can provide as an optimization. +The `@key(fields: "id")` directive explicitly declares that `Product` is identified by the `id` field. This is useful in a subgraph that extends `Product` but does not define its own lookup, so composition cannot infer the key from lookup arguments. -## Node Pattern (Relay Global Object Identification) +The `fields` value uses GraphQL field names, not C# member names. -The [Relay Global Object Identification specification](https://relay.dev/graphql/objectidentification.htm) defines a standard way to fetch any entity by a globally unique ID using a `node(id: "...")` query. Fusion supports this pattern through the `[NodeResolver]` attribute and `AddGlobalObjectIdentification()`. +An entity can have multiple keys. Each `@key` directive on a type represents one key. -### Enabling the Node Pattern +**GraphQL schema with scalar composite key** -**In each subgraph**, register global object identification: - -```csharp -builder.Services - .AddGraphQLServer() - .AddGlobalObjectIdentification() - .AddTypes(); -``` - -Then mark one lookup per entity type with `[NodeResolver]`: - -```csharp -[QueryType] -public static partial class ProductQueries -{ - [Lookup, NodeResolver] - public static async Task GetProductByIdAsync( - int id, - IProductByIdDataLoader productById, - CancellationToken cancellationToken) - => await productById.LoadAsync(id, cancellationToken); +```graphql +type Product @key(fields: "id") @key(fields: "sku category") { + id: ID! + sku: String + category: String } ``` -`[NodeResolver]` tells HotChocolate that this lookup is the Relay node resolver for `Product`. The `node(id: "...")` query decodes the global ID, determines the entity type, and dispatches to the correct `[NodeResolver]` lookup. +**GraphQL schema with nested composite key** -**During composition**, enable global object identification with the `--enable-global-object-identification` flag: +```graphql +type Product @key(fields: "id") @key(fields: "sku tenant { id }") { + id: ID! + sku: String + tenant: Tenant +} -```bash -nitro fusion compose \ - --source-schema-file Products/schema.graphqls \ - --source-schema-file Reviews/schema.graphqls \ - --archive gateway.far \ - --enable-global-object-identification +type Tenant { + id: ID! +} ``` -This adds the `node` and `nodes` query fields to the composite schema. Without this flag, `[NodeResolver]` annotations are ignored during composition. - -### When to Use It - -The node pattern is useful when: - -- Your clients use Relay or a client that expects global object identification -- You want a uniform way to refetch any entity by a single opaque ID -- The fusion-demo uses `[NodeResolver]` on every entity lookup as a standard practice +## GraphQL Global Object Identification -You can have at most one `[NodeResolver]` per entity type per subgraph. If an entity has multiple lookups (by ID, by username, etc.), only the primary one should be the `[NodeResolver]`. +If your subgraphs implement GraphQL Global Object Identification, with a `node` field on `Query` and a `Node` interface, you already have a strong entity identity contract. You can build on this by using `node` as a lookup and treating types that implement `Node` as entities. -## Putting It All Together +**GraphQL schema** -Here is a summary of the patterns for the most common entity scenarios: - -**You own the entity (primary subgraph):** - -- Define the full type with all fields -- Add a public `[Lookup]` resolver (with `[NodeResolver]` if using Relay) -- Use `[DataLoader]` for batch resolution - -**You extend the entity (secondary subgraph):** +```graphql +type Query { + node(id: ID!): Node @lookup +} -- Create an entity stub with just the key field and your new fields -- Add an internal `[Lookup, Internal]` resolver -- Use `[BindMember]` if replacing a foreign key with an entity reference -- Mark any duplicated non-key fields with `[Shareable]` +interface Node @key(fields: "id") { + id: ID! +} +``` -**You need data from another subgraph in a resolver:** +If you are using Hot Chocolate as a subgraph, set `MarkNodeFieldAsLookup` and Hot Chocolate will mark the generated `node` field as a lookup automatically. -- Use `[Require(...)]` on the resolver argument to declare the dependency -- The gateway fetches the required data automatically -- Required arguments are hidden from the composite schema +**C# configuration** -**Your entity can be identified by multiple keys:** +```csharp +builder + .AddGraphQL() + .AddGlobalObjectIdentification(o => o.MarkNodeFieldAsLookup = true); +``` -- Add multiple `[Lookup]` resolvers (by ID, by username, by SKU, etc.) -- The gateway uses whichever key is available +> If GraphQL Global Object Identification is enabled at the gateway level, every entity resolvable through the `node` field becomes a public entry point. Use explicit internal lookups for entities you do not want exposed as public entry points. ## Next Steps -- **Need cross-subgraph field dependencies?** The `[Require]` attribute enables resolvers to depend on data from other subgraphs. Cross-subgraph data dependencies, including complex field mapping, will be covered in detail in future documentation. -- **Want to understand composition rules?** See [Composition](/docs/fusion/v16/composition) for how types are merged, what causes composition errors, and how to fix them. +- **Need field ownership rules?** See [Composition](/docs/fusion/v16/composition) for how field ownership, `@shareable`, and composition validation work. +- **Need argument mapping and cross-subgraph dependencies?** The `@is` and `@require` directives are covered in dedicated pages. +- **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/getting-started.md b/website/src/docs/fusion/v16/getting-started.md index e47a572f6e4..d871e1780c1 100644 --- a/website/src/docs/fusion/v16/getting-started.md +++ b/website/src/docs/fusion/v16/getting-started.md @@ -1059,7 +1059,7 @@ public sealed record Product(int Id) } ``` -Both subgraphs define `Product.name` and mark it `[Shareable]`. Composition succeeds, and the gateway can resolve `name` from either subgraph depending on what else the query needs. If a query only asks for `product.name` and `product.reviews`, the gateway might fetch everything from the Reviews subgraph in a single call instead of making a separate trip to the Products subgraph. +Both subgraphs define `Product.name` and mark it `[Shareable]`. Composition succeeds, and the gateway can resolve `name` from either subgraph depending on what else the query needs. If a query only asks for `product.name` and `product.reviews`, the gateway will fetch everything from the Reviews subgraph in a single call instead of making a separate trip to the Products subgraph. ### The Rule @@ -1077,26 +1077,7 @@ You now have a working Fusion setup: two subgraphs contributing to one composed - **I want to add another subgraph to this project**: [Adding a Subgraph](/docs/fusion/v16/adding-a-subgraph) - **I want to understand entities more deeply**: [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) +- **I need cross-subgraph field dependencies (`[Require]`)**: [Entities and Lookups](/docs/fusion/v16/entities-and-lookups) - **I need to deploy this**: [Deployment & CI/CD](/docs/fusion/v16/deployment-and-ci-cd) - **I need to secure this**: [Authentication and Authorization](/docs/fusion/v16/authentication-and-authorization) - **I'm coming from Apollo**: [Coming from Apollo Federation](/docs/fusion/v16/migration/coming-from-apollo-federation) - -**Other useful resources:** - -- **Use `[Require]` for cross-subgraph data dependencies.** If a field resolver needs data that lives in another subgraph, use the `[Require]` attribute on a method argument to declare the dependency. For example, a Shipping subgraph calculating delivery estimates might need a product's weight from the Products subgraph: - - ```csharp - public int GetDeliveryEstimate( - string zip, - [Require] int weight) - { - // weight is fetched from the Products subgraph automatically - return CalculateShipping(zip, weight); - } - ``` - - When the argument name matches the entity field name (like `weight` above), Hot Chocolate infers the mapping automatically, so you can use `[Require]` without parameters. If the names differ, specify the entity field explicitly: `[Require("weight")] int productWeight`. The gateway resolves the required fields from their owning subgraph before calling your resolver. The required arguments are hidden from the composite schema. Clients never see them. - -- **You may see `[NodeResolver]` in demo code alongside `[Lookup]`.** This enables Relay-style global object identification (`node(id: ...)` queries). The fusion-demo uses it on every entity lookup. It is not required for basic Fusion setups. - -- **Explore the fusion-demo repository.** The [ChilliCream fusion-demo](https://github.com/ChilliCream/fusion-demo) is a full production-style example with eight subgraphs, .NET Aspire orchestration, PostgreSQL databases, authentication, subscriptions, and CI/CD pipelines. It shows everything this guide simplified for learning purposes. diff --git a/website/src/docs/shared/fusion/entities-public-vs-internal-lookup.drawio b/website/src/docs/shared/fusion/entities-public-vs-internal-lookup.drawio new file mode 100644 index 00000000000..76d10cff61d --- /dev/null +++ b/website/src/docs/shared/fusion/entities-public-vs-internal-lookup.drawio @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/src/docs/shared/fusion/entities-public-vs-internal-lookup.png b/website/src/docs/shared/fusion/entities-public-vs-internal-lookup.png new file mode 100644 index 00000000000..be3bf7c9522 Binary files /dev/null and b/website/src/docs/shared/fusion/entities-public-vs-internal-lookup.png differ