skip to content

How does a GraphQL server give behaviour to a custom directive applied in its schema?

level: middleimportance: should knowfreq 38%

answer

  1. Two moments: build time or request time
  2. Wrap the resolver, or read it as you go
  3. Baked in once versus looked up each time
  4. Arguments are handled before the resolver runs
  5. Nothing cascades from a type to its fields

basics

~20 s

Two shapes. Either a build-time pass walks the schema, finds each application and rewrites what it found — usually wrapping the field's resolver — or the code running a field reads the annotation off that field's schema definition at execution time and branches on it.

solid answer

~50 s

Since GraphQL supplies no semantics, the server must find the applications itself. The common approach is a **build-time transform**: after the executable schema is assembled, walk every location, check whether the directive is applied, read its arguments, and replace the field's resolver with a wrapper that adds the behaviour. Cost is paid once and resolvers stay ignorant of the concern. The alternative is **execution-time inspection**: a generic hook around field execution reads the applied directive from the field's schema definition and branches, which is what you want when the behaviour depends on request state rather than only on the schema. Two limits matter. Wrapping a resolver reaches only output-field applications — an annotation on an argument or input field must be honoured before the resolver runs. And an annotation on a type or on the schema does not cascade to its fields; if you want that, you expand it yourself and define what happens when both levels are annotated.

code

pseudocode · 16 lines
pseudocode
function applyUnitDirective(schema):
    matched = 0
    for type in schema.types:
        for field in type.fields:
            applied = field.appliedDirective("unit")
            if applied == null:
                continue
            matched = matched + 1
            symbol = applied.argument("symbol")
            inner  = field.resolver
            field.resolver = function(parent, args, context):
                raw = inner(parent, args, context)
                return convert(raw, symbol)
    if matched == 0:
        fail("no @unit applications found - handler and schema disagree")
    return schema

go deeper

for a junior

Know that behaviour comes from server code, and that the usual mechanism is replacing the annotated field's resolver with one that calls the original and adds something around it.

for a middle

Explain both moments — build-time schema transform versus execution-time inspection — with their costs, and note that argument-level annotations must be honoured before the resolver runs.

for a senior

Demonstrate the operational judgement: what breaks when the schema is rebuilt or extended after the pass, how you define ordering when several directives share a field, and when a directive is the wrong abstraction for three fields.

for a principal

Own the readability tradeoff. Directive machinery moves behaviour away from the resolver an engineer is reading; be able to argue when consistency across many fields justifies that and how the team keeps the indirection discoverable.

### The server has to go looking Because GraphQL defines no execution semantics for a custom directive, giving one behaviour means writing code that (a) finds the applications and (b) changes what happens at those locations. There are two well-established shapes, and a mature codebase often uses both. ### Strategy A — transform the schema once, at build time After the executable schema is assembled, walk it: every field definition, argument definition, enum value, type. At each location, ask whether the directive you care about is applied there and read its argument values. When it is, replace what you found — most often by swapping the field's resolver for a wrapper that calls the original and adds the behaviour around it. The appeal is that the cost is paid once. At request time there is no directive lookup at all; there is simply a resolver that happens to convert kilograms, or trim a string, or record a metric. It also keeps the concern out of every resolver: the resolver an engineer writes for `Animal.birthWeight` knows nothing about units. The catches are real. The wrapping exists only in the schema object that pass produced — if anything later rebuilds the schema, or prints it to SDL and re-parses it, the *text* of the applied directive survives and the wrapper does not. Merging additional SDL after the pass has run produces fields that carry the annotation and none of the behaviour. And when several directives apply to one field, wrapping order becomes visible behaviour that you, not the specification, have to define and document. ### Strategy B — read the applied directive at execution time Alternatively, leave the schema alone and have the code that runs a field consult the field's own schema definition: is the directive applied here, and with what arguments? Branch accordingly. This usually lives in a generic hook or middleware that wraps every field's execution, rather than being copy-pasted into individual resolvers. This is the right shape when the decision genuinely depends on request state — the same annotation producing different behaviour per caller, per locale, per feature flag — because nothing was frozen at build time. It also survives schema rebuilds, since there is nothing baked in to lose. The cost is a lookup on every annotated field on every request, which is worth memoizing by field definition, and a diffuse concern: whatever runs the field must remember to check, so a resolution path that bypasses the hook silently bypasses the directive. ### Directives that are not on a field definition Wrapping a resolver only reaches directives applied at output-field locations. An annotation on an argument definition or an input field has to be honoured *before* the resolver runs, by transforming or validating the incoming argument values in the wrapper. An annotation on an object type or on the schema does not cascade to fields on its own — the specification defines no inheritance. If you want `@unit` on the `Animal` type to apply to every weight field beneath it, you write that expansion yourself during the walk, and you decide and document what happens when both the type and the field carry the directive. ### Directives consumed before runtime Not every directive should reach the server at all. Some are read by a schema linter to enforce a naming or ownership convention. Some are read by a typed client generator so an annotated field produces a different generated type. Some are read by a composition step and stripped from the schema it publishes. These are legitimate uses, and being explicit about which consumer reads a given directive is most of what keeps a directive-heavy schema comprehensible. ### Choosing, and being honest about the cost For a small team the deciding question is usually: does the behaviour depend only on the schema, or also on the request? Schema-only behaviour — unit conversion, formatting, a static cost weight, a cache hint — is a natural build-time transform. Request-dependent behaviour belongs at execution time. The tradeoff to name in an interview is comprehensibility. A directive moves behaviour out of the resolver an engineer is reading and into a pass they may not know exists. That is a genuine win for consistency across hundreds of fields and a genuine loss for local readability — which is why directive machinery earns its place when many fields need the same treatment, and rarely when three do.

  • A custom directive is applied to an object type. How do its fields learn about it?
    Only if you make them. GraphQL defines no inheritance from a type to its fields, so a walk that expands the type-level application onto each field is code you write. You also have to decide and document precedence when a field carries its own application of the same directive — override, merge or error are all defensible, but the specification picks none of them.
  • Two custom directives are applied to the same field. What decides the order their behaviour applies in?
    Your implementation does. The specification preserves the order the applications appear in the document text but assigns no execution meaning to it, so with resolver wrapping the order you wrap in determines which behaviour is outermost. Treat it as an explicit design decision, document it, and test a field carrying both — otherwise it becomes an accident of iteration order.
  • Why would you prefer execution-time inspection over a build-time transform?
    When the behaviour depends on the request rather than only on the schema — different results per caller, locale or feature flag — nothing useful can be frozen at build time. It also survives a schema rebuild, since there is no wrapper to lose. The costs are a per-field lookup worth memoizing and the risk that a resolution path bypassing the hook silently bypasses the directive.

saying these in an interview costs you the question

  • Assumes a directive on a type applies to its fields automatically
  • Thinks resolver wrapping can validate input arguments
  • Believes the specification defines multi-directive ordering
  • Says printing the schema to SDL preserves the wrapping
  • Cannot name any moment at which the annotation is read
  • Treats every directive as something the server must run

context