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 tiny: lookup → build → return. Load a single entity by its internal ID and
attach a Global ID via
ctx.globalIDFor(<Type>.Reflection, internalId). 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.
Nullability and errors¶
- Do return
nullfor missing or unknown values when the schema field is nullable. 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.
- Don't throw exceptions for "not found" cases. Reserve exceptions for unexpected conditions (I/O failures, decoding errors, programming bugs).
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 results in the same order as the input contexts.
- 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.objectValue, 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(<Type>.Reflection, internalId)— this keeps them opaque and stable across module or storage backends. - Do annotate
IDfields and arguments 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 base64-decode Global IDs or rely on their internal layout.
- Don't embed business logic or access-control information into IDs.
Scopes¶
- Do define a
defaultscope for general-availability fields so requests without an explicit scope still see something useful. 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.