Explain the full exception-resolution chain in Spring for GraphQL and how you design partial per-field error behavior for a list query.
answer
- chain: local handler -> advice -> resolver bean -> masked default
- null/empty-Mono = unresolved (pass through); empty list = suppress
- every GraphQLError has a path -> per-field/per-item errors
- nullable field = partial; non-null bubbles up
- batch/DataLoader: fail one key vs whole batch
basics
~20 sWhen a fetcher throws, Spring runs an ordered resolver chain (controller-local @GraphQlExceptionHandler, then @ControllerAdvice, then DataFetcherExceptionResolver beans, then the masked INTERNAL_ERROR default). Each error carries a path, so a failing element in a list produces one error while sibling elements still return data — a partial response.
solid answer
~50 sEvery data-fetcher exception flows through an ordered chain: (1) controller-local @GraphQlExceptionHandler methods, (2) global @ControllerAdvice handlers, (3) standalone DataFetcherExceptionResolver beans, (4) the built-in fallback producing a masked INTERNAL_ERROR. A resolver returning null/empty-Mono is 'unresolved' and defers to the next; returning a list resolves it; returning an empty list suppresses it. Because GraphQL resolves each field independently and every GraphQLError carries a path, a failure while resolving one element of a list yields a single error at that element's path while sibling elements keep their values — partial results. Design levers: declare fields nullable so a null doesn't bubble up and wipe the parent; map per-item exceptions to precise paths (GraphqlErrorBuilder.path(...) / newError(env)); choose classifications that let clients degrade gracefully; and, for batch loads, decide whether one bad item fails the whole batch or just that entry. Keep messages masked unless explicitly safe.
code
java · 28 lines// Per-item errors in a batch load: one bad book doesn't fail the list.
@Controller
class BookController {
@BatchMapping
Map<Book, Author> author(List<Book> books) {
Map<Book, Author> result = new HashMap<>();
for (Book b : books) {
try {
result.put(b, authorService.load(b.authorId()));
} catch (AuthorNotFoundException ex) {
// leave this key out / null -> field is null for THIS book only
result.put(b, null);
}
}
return result; // other books' authors resolve normally
}
// Central mapping so per-item failures get a precise classification.
@GraphQlExceptionHandler
GraphQLError handle(AuthorNotFoundException ex, DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env) // env carries the element's path
.errorType(ErrorType.NOT_FOUND)
.message("Author not found")
.build();
}
}
// Schema: author must be nullable (Author, not Author!) for graceful partial results.go deeper
Understand that per-field resolution enables partial results and each error has a path.
List the resolver chain order and the pass-through (null) vs suppress (empty list) semantics.
Explain null-propagation's effect on partial results and central mapping of security/validation exceptions.
Architect the error strategy: nullability as a degradation lever, per-item batch behavior, classification/extensions as a versioned client contract, and disciplined message masking.
## The resolution chain end-to-end When a data fetcher throws, Spring for GraphQL routes the throwable through a **prioritized chain** of `DataFetcherExceptionResolver`s. Annotation handlers are adapted into this chain by `AnnotatedControllerConfigurer`: 1. **Controller-local `@GraphQlExceptionHandler`** — methods in the *same* `@Controller` whose fetcher failed. Highest precedence, most specific. 2. **Global `@GraphQlExceptionHandler`** in `@ControllerAdvice` beans — application-wide. 3. **Standalone `DataFetcherExceptionResolver` / `...Adapter` beans** — explicit resolver components. 4. **Built-in fallback** (`ExceptionResolversExceptionHandler`) — logs the real exception under the `executionId` and emits a masked **`INTERNAL_ERROR`**. **Pass-through contract:** each layer signals *unresolved* by returning `null` / `Mono.empty()` (or `null` from `resolveToSingleError`), which advances to the next layer. Returning a **non-empty list** resolves it. Returning an **empty list** deliberately **suppresses** the error (field is null, no `errors` entry). The first layer that resolves wins; later layers don't run. ## Why partial per-field errors 'just work' GraphQL executes the query as a tree, and **each field has its own data fetcher**. graphql-java tracks the current field's **path** during execution. When a fetcher throws, the resolved `GraphQLError` is tagged with that path (automatically when you use `GraphqlErrorBuilder.newError(env)`, since `env` knows the path). So: - For `books { title author { name } }`, if `author` for book #2 fails, you get one error at path `["books", 1, "author"]`, book #2's `author` is `null`, and **every other book and field is unaffected**. That is the essence of **partial results**: `data` present (with targeted nulls) + `errors` array listing exactly what failed and where. ## The null-propagation design lever The schema's nullability controls how far a failure spreads: - **Nullable field** (`author: Author`) → failure produces `null` *at that field*; siblings survive → true partial result. - **Non-nullable field** (`author: Author!`) → GraphQL can't store `null`, so the `null` **bubbles up** to the nearest nullable ancestor. If the whole path to `Query` is non-null, the entire `data` becomes `null` even though other branches technically succeeded. **Design implication:** if you want graceful degradation, make fields that can independently fail **nullable**. Reserve non-null for invariants where a missing value genuinely invalidates the parent. ## Designing list / batch error behavior For collection fields (often paired with `@BatchMapping` / DataLoader to avoid N+1): - **Per-item exceptions** should map to **per-item paths** so one bad row doesn't poison the list. With a batch loader, decide whether an exception fails the whole batch (all those items error) or whether you return a partial map (some items null with individual errors). Returning a map missing some keys, or nulls for failed keys plus targeted errors, keeps the rest of the list intact. - **Choose classification per failure** so clients can react: `NOT_FOUND` for a missing item vs `FORBIDDEN` for an unauthorized one vs `INTERNAL_ERROR` for a genuine bug. ## Cross-cutting concerns and layering strategy - **Security exceptions** (`AuthenticationException`, `AccessDeniedException`) → map centrally (a resolver or `@ControllerAdvice`) to `UNAUTHORIZED`/`FORBIDDEN`. Method security (`@PreAuthorize`) throwing inside a fetcher lands in this same chain. - **Validation** → map to `BAD_REQUEST`, potentially multiple errors via `resolveToMultipleErrors` / a `List<GraphQLError>`-returning handler. - **Everything else** → let it fall through to masked `INTERNAL_ERROR`, and correlate via `executionId` in logs. Never blanket-copy `ex.getMessage()` — that reintroduces the leak the default guards against. ## Gotchas at scale - **Exceptions before execution** (document parse/validation) never reach this chain — no field, no path; handle via interceptors/instrumentation. - **Ordering surprises**: a controller-local handler silently shadows a global one for the same type; make the split intentional. - **Suppressing errors (empty list)** hides failures from clients — use sparingly and log. - **Extensions as contract**: `classification` and any custom `extensions` you emit become a de-facto API; version and document them. - **Reactive vs blocking**: use the `DataFetcherExceptionResolver` interface (returns `Mono`) when the mapping itself needs async work; the Adapter is for synchronous mapping.
- You want a failing list element to null just itself, not the whole list. What schema and mapping choices enforce that?Declare the element's failing field nullable so the null doesn't bubble up past it, and map the per-item exception to a GraphQLError built from the DataFetchingEnvironment (so it carries that element's path). In a batch loader, return null/omit only the failed key rather than throwing for the whole batch.
- How do method-security failures like @PreAuthorize denials fit into this chain?When @PreAuthorize throws inside a data fetcher, the AccessDeniedException flows through the same resolver chain. You map it centrally (a resolver or @ControllerAdvice handler) to ErrorType.FORBIDDEN (or UNAUTHORIZED for missing authentication), otherwise it falls through to the masked INTERNAL_ERROR.
saying these in an interview costs you the question
- Claiming one failed field always fails the entire query (ignores per-field resolution)
- Thinking a non-null field's failure still yields a partial result at that position
- Blanket-mapping every exception's raw message to the client, leaking internals
- Believing parse/validation errors go through the DataFetcherExceptionResolver chain