Best Practices
This page collects the recurring "do" and "don't" guidance from across the tutorial sections into one reference. The deep explanations stay in the tutorials; this page is for skimming when you want a quick sanity check before writing or reviewing a resolver.
Resolver responsibility¶
- Do keep node resolvers small: lookup → build → return. Load a single entity by its internal ID and attach a Global ID using the language-specific call shown below. See Node Resolvers.
- Do put computation, formatting, and lightweight relationship dereferencing in field resolvers. See Field Resolvers.
- Do isolate concerns: nodes fetch, fields compute, batch resolvers aggregate. Don't mix loading logic inside field resolvers.
- Don't perform per-request joins or heavy business logic inside a node resolver — you'll lose batching opportunities elsewhere.
- Don't assume execution order between independent resolvers. Express dependencies through
objectValueFragment, not by sequencing.
Both languages use the same resolver responsibilities. The identity call differs only in how the context ID is accessed:
Nullability and errors¶
- Do return
nullfor missing or unknown values when the schema field is nullable. Java returnsCompletableFuture.completedFuture(null). GraphQL treatsnullas an expected outcome and surfaces partial results to clients. - Do match the field's nullability: if the schema field is non-null, ensure your resolver always produces a value.
- Do report a missing node from a node resolver as an error: throw for a single node, or return
FieldValue.ofError(...)for its context in a batch. A field resolver may returnnullfor an expected missing value when the field is nullable. See Node Resolvers.
Batching and N+1¶
- Do opt into batching with
@resolver(isBatching: true)whenever the same field is selected across many parent objects in a single operation. See Batch Resolvers. - Do deduplicate keys before hitting the data layer and return one result for every input context, following the language-specific contract in Batch Resolution: Kotlin field batches use a positional
List<FieldValue<T>>; node batches in either language useMap<Context, FieldValue<T>>; Java field batches useMap<Context, T>(orMap<Context, FieldValue<T>>when overridingbatchResolveWithErrors). Maps must use the exact inputContextobjects as keys. - Do migrate a single-field resolver to a batch resolver as soon as you notice it executes the same repository call per parent.
- Don't call the data layer once per context inside
batchResolve. The point of batching is one fetch per unique key set. - Don't loop lookups inside a non-batched
resolvewhen the query can select many parents.
Fragments and parent data¶
- Do request only the parent fields your resolver actually uses in
objectValueFragment. Overly broad fragments increase planning and execution cost. - Do prefer the parent fragment over additional lookups: if the data is already in
ctx.getObjectValue(), use it. - Don't rely on getters that aren't covered by the fragment — accessing a field that wasn't requested raises
UnsetFieldException.
Identity and Global IDs¶
- Do treat Global IDs as opaque at the network boundary. Clients should pass them around without parsing.
- Do generate IDs in resolvers via
ctx.globalIDFor(MyNode.Reflection, internalId)in either language — this keeps them opaque and stable across module or storage backends. - Do annotate
IDfield arguments and input fields with@idOf(...)to bind them to a concrete GraphQL type so resolvers and tooling can handle them type-safely. See Global IDs. - Don't ask clients to decode Global IDs or otherwise rely on their internal format.
- Don't embed business logic or access-control information into IDs.
Scopes¶
- Do annotate client-visible types and extensions consistently when using scopes, and configure which executable schema view handles each request. There is no implicit default scope. See Scope Directive.
- Do keep scopes orthogonal — non-overlapping responsibilities reduce confusion and accidental exposure.
- Do apply
@scopeexplicitly to sensitive fields and pick a single source of truth for which scopes apply to a request (JWT claims, session, request context). - Don't rely on
@scopealone for authorization. Scopes govern schema visibility; complement them with application-level checks for data-level access control.
Testing¶
- Do test integration flows end-to-end by issuing real GraphQL queries against a configured Viaduct instance. This catches misaligned IDs, missing fragments, and scope-visibility bugs that unit tests miss.
- Do add per-scope integration tests when a tenant exposes scoped fields.
- Don't lean exclusively on unit tests for resolvers — most resolver bugs only surface during planning or execution.