Class 6: Server Implementation in Spring GraphQL
Duration: 30 minutes | Difficulty: Intermediate to Advanced | Prerequisites: Class 5 of this course, and the Spring GraphQL tutorial through class 5.
This class builds on that tutorial's Movie Database service and error-handling setup, so have the service from those classes at hand.
What You'll Learn
By the end of this class, you will:
- Know Spring for GraphQL's default exception behavior and why masking-by-default is the correct baseline
- Implement Class 4's error taxonomy in a
DataFetcherExceptionResolverAdapterand know when@GraphQlExceptionHandlerfits better - Keep business outcomes out of the exception path entirely, wiring Class 5's
CreateReviewResponsein a controller - Handle the two awkward corners: errors inside batch loaders and errors inside subscription streams
- Log failures with correlation IDs so support can join a user report to a stack trace
- Test error responses with
GraphQlTester: expected errors asserted, unexpected errors failing the test - Map the same responsibilities onto Netflix DGS, Apollo Server, and Hot Chocolate
- Know what changes in a federated graph, where a router stands between your subgraph's errors and the client
The default behavior, and why it is right
Start a Spring GraphQL service, have a resolver fail with an uncaught exception, and look at what the client receives. The Spring for GraphQL reference describes it:
If an exception remains unresolved, by default it is categorized as an
INTERNAL_ERRORwith a generic message that includes the category name and theexecutionIdfromDataFetchingEnvironment. The message is intentionally opaque to avoid leaking implementation details.
On the server side, the same section says, unresolved exceptions are logged at ERROR level along with the executionId, which correlates the log entry with the error sent to the client. Resolved exceptions are logged at DEBUG level.
This default encodes the security posture Class 4 argued for, and it is worth naming before we customize anything. The client receives a classification and a correlation handle, the server log records the full exception, and every detail of your implementation stays on the server. Each customization below makes the information the client receives more precise; none of them should ever expose more of the implementation than the default does. The other frameworks land nearby, though not identically. Apollo Server 5 tags unexpected errors with extensions.code: INTERNAL_SERVER_ERROR and, per its docs, omits the development-mode stacktrace extension "if the NODE_ENV environment variable is set to either production or test". It does pass the thrown exception's message through to the client, unless a formatError hook masks it.
Hot Chocolate replaces the message with a generic one by default, per its errors documentation. Its IncludeExceptionDetails option adds the detail back, and it is on by default while a debugger is attached. Spring and Hot Chocolate therefore both mask messages by default. That is the right baseline, because anyone probing an API for weaknesses studies its error responses closely.
One transport detail belongs here too, because the status codes of Class 3 are the server's job. Spring for GraphQL's transports reference says that application/graphql-response+json is "the content type of choice" when the client does not state a preference. Such responses "will also use 4xx statuses if the GraphQL document sent by the client cannot be parsed or is considered invalid by the GraphQL engine", while application/json responses keep 200 in that case. Spring does not send 294 for partial results yet. Issue #1314 tracks that for the 2.1.x line, so a Spring server sends 200 for a response that carries both data and errors until it lands.
The resolver chain
Spring for GraphQL routes every customization in this class through one contract: a chain of DataFetcherExceptionResolver beans. An application registers any number of them, and they are consulted in order until one resolves the exception to a List<GraphQLError>; an exception no resolver claims falls through to the masked default above. The framework provides DataFetcherExceptionResolverAdapter as "a convenient base class with protected methods resolveToSingleError and resolveToMultipleErrors", which handles the reactive plumbing.
Here is Class 4's error taxonomy as one adapter on the Movie Database. The exceptions are the service layer's vocabulary; the resolver is the single translation point where they become wire-format errors, with both Spring's extensions.classification and the course's extensions.code:
@Component
public class TaxonomyExceptionResolver extends DataFetcherExceptionResolverAdapter {
private static final Logger log = LoggerFactory.getLogger(TaxonomyExceptionResolver.class);
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
if (ex instanceof EntityNotFoundException nfe) {
return error(env, ErrorType.NOT_FOUND, "NOT_FOUND",
nfe.getMessage(), Map.of("entityType", nfe.getEntityType()));
}
if (ex instanceof RatingsUnavailableException rue) {
return error(env, ErrorType.INTERNAL_ERROR, "UNAVAILABLE",
"Ratings are temporarily unavailable",
Map.of("retryAfterSeconds", rue.getRetryAfterSeconds()));
}
return null; // unknown exception: fall through to the masked INTERNAL_ERROR default
}
private GraphQLError error(DataFetchingEnvironment env, ErrorType classification,
String code, String message, Map<String, Object> extra) {
Map<String, Object> extensions = new LinkedHashMap<>(extra);
extensions.put("code", code);
return GraphqlErrorBuilder.newError(env)
.errorType(classification)
.message(message)
.extensions(extensions)
.build();
}
}
ErrorType in the listing is Spring's org.springframework.graphql.execution.ErrorType, whose values are BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND and INTERNAL_ERROR; graphql-java has a different ErrorType enum. The listing makes three deliberate choices:
GraphqlErrorBuilder.newError(env)seeds the error with thepathandlocationsof the failing field, so the client-side correlation from Class 2 works without further effort.- The
UNAVAILABLEbranch is the transient quadrant of the error taxonomy: a stable code, aretryAfterSecondshint, and a message that does not name the backend that failed. - The final
return nullis doing real work: an exception this resolver does not recognize is by definition the persistent-failure quadrant, and the masked default plus its ERROR-level log entry is exactly the treatment Class 4 prescribed for it.
Resist the temptation to catch everything and guess a classification; a wrong UNAVAILABLE teaches clients to retry a failure that cannot succeed.
Security exceptions are missing from the listing on purpose. With Spring Security on the classpath, Spring Boot registers Spring for GraphQL's security exception resolver, SecurityDataFetcherExceptionResolver (or its reactive counterpart). It turns an AuthenticationException into an UNAUTHORIZED error, and an AccessDeniedException into FORBIDDEN, or into UNAUTHORIZED when the caller is anonymous, as its source code shows. A branch of your own for these exceptions would have to repeat that anonymous-caller check, or it would tell a caller who has not logged in that access is forbidden. Clients of a Spring server can branch on extensions.classification for these two cases.
One cross-ecosystem note, documented at length in the Spring course's error-handling class: graphql-java writes the classification under extensions.classification, and Spring inherits that, while Apollo Server uses extensions.code. Neither is more official. The listing above emits both keys so that dashboards and clients written against either convention keep working; if your graph is Spring-only, classification alone is fine, but pick deliberately and document it.
The annotated alternative
For exceptions that arise in controller methods, Spring offers the same mechanism at a higher level: @GraphQlExceptionHandler methods, in the controller itself or in a @ControllerAdvice class. Per the controllers reference, a handler declared in a controller applies to exceptions from that controller; declared in a @ControllerAdvice, it applies across controllers. Handlers can return any of these:
- A single
GraphQLError. - A
Collection<GraphQLError>. void, which swallows the exception.Object, resolved at runtime to a single error, several errors, ornullto swallow the exception.- A
Monoof any of those, for asynchronous resolution.
The reference describes the scoping rule:
Exception handling via
@GraphQlExceptionHandlermethods is applied automatically to controller invocations. To handle exceptions from othergraphql.schema.DataFetcherimplementations, not based on controller methods, obtain aDataFetcherExceptionResolverfromAnnotatedControllerConfigurer, and register it inGraphQlSource.Builderas a DataFetcherExceptionResolver.
Spring Boot does that registration for you: its GraphQL auto-configuration adds that resolver to the chain. In a Boot application, @ControllerAdvice handlers therefore also cover hand-registered DataFetcher implementations and batch loaders, and @ControllerAdvice plus @GraphQlExceptionHandler is the idiomatic choice. It reads like familiar Spring MVC.
One ordering detail decides how the two mechanisms combine. For an exception raised in a controller method, the annotated handlers run first, inside the controller's own data fetcher, before any resolver bean sees the exception. A catch-all handler for Exception, such as the one in the Spring course's class 5, therefore claims every controller exception before a resolver bean such as TaxonomyExceptionResolver can see it. Keep the error taxonomy in one place: as handler methods next to the catch-all, or in a resolver like the listing above with the catch-all removed. A DataFetcherExceptionResolverAdapter is the natural home when the application builds its GraphQlSource without Spring Boot, or when a team wants the whole error taxonomy in one class.
Keeping outcomes out of the exception path
Class 5 moved expected business outcomes into the schema, and the server half of that promise is a controller that returns them as values. This is a discipline the team keeps, because the framework does not enforce it. A service can raise ReviewTextTooLongException and map it in a handler. That design works right up until the exception escapes through a code path without a handler, arriving at the client as a masked INTERNAL_ERROR, and because CreateReviewResponse! is non-null, the error propagates to the root and data becomes null. Outcomes the schema declares should never ride the exception channel at all:
@Controller
public class ReviewController {
@MutationMapping
public CreateReviewResponse createReview(@Argument CreateReviewInput input) {
Movie movie = movieRepository.findById(input.movieId()).orElse(null);
if (movie == null) {
return CreateReviewResponse.failure(
new MovieNotFound("No movie with that ID", List.of("input", "movieId"), input.movieId()));
}
if (input.text().length() > MAX_REVIEW_LENGTH) {
return CreateReviewResponse.failure(
new ReviewTextTooLong("Review is too long", List.of("input", "text"), MAX_REVIEW_LENGTH));
}
Review review = reviewService.create(movie, input.text(), input.stars());
return CreateReviewResponse.success(review);
}
}
The exception channel stays reserved for what Class 4 classified as failures: the repository timing out, the transaction failing. The result is a clean separation visible in every response: userErrors populated means the system worked and the request's outcome was negative; the errors list populated means something broke. Code review enforces this line, and the review question is simple: could this throw statement fire on a healthy system because of what a user asked for? Then it should be a return value.
Batch loaders and subscriptions
Two execution contexts sit outside the ordinary resolver path and need their own attention.
Batch loaders. The Movie Database uses @BatchMapping to solve the N+1 problem, which routes field resolution through java-dataloader. Error handling changes shape here because one invocation now serves many response positions. When a batch function fails as a whole, every response position waiting on that batch receives the failure. One bad record can error fifty siblings that would have resolved fine. When per-key failure matters, return per-key results instead of raising an exception for the batch. java-dataloader's Try wrapper exists for exactly this: a batch function marks individual keys as failed while the rest of the batch succeeds. @BatchMapping methods return a Map or a collection of plain values, so they cannot use it. Per-key Try results need a DataLoader created with DataLoaderFactory.newDataLoaderWithTry and registered outside @BatchMapping. A failed key reaches the waiting data fetcher as an exception, and that exception goes through the same DataFetcherExceptionResolver chain, so TaxonomyExceptionResolver above remains the single translation point.
Subscriptions. Class 1 noted that a subscription is a response stream whose every event is an execution result, so errors during an event's execution behave normally. The special case is the stream itself failing, and the reference is explicit about why the ordinary mechanism cannot see it:
DataFetcherExceptionResolvercannot resolve errors from a subscriptionPublisher, since the dataDataFetcheronly creates thePublisherinitially. After that, the transport subscribes to thePublisherthat may then complete with an error.
For that case Spring provides a separate hook, SubscriptionExceptionResolver, which an application registers to resolve exceptions from a subscription Publisher into GraphQL errors sent to the client before the stream terminates. If your schema has subscriptions, register a SubscriptionExceptionResolver as well, because stream failures need the same error codes as every other failure.
Logging with a reference to follow
The masked default already gives every unresolved failure a reference: the executionId, which appears in the opaque message and in the ERROR log line. The Spring course's class 5 turns that into a support-friendly rule with its catch-all handler:
- Generate a short reference for every unexpected failure.
- Log the full exception against it.
- Return the reference in
extensions.
The client sees a generic message and a reference such as a1b2c3, and a user report containing that reference leads support straight to the stack trace. Wire the same reference into your tracing system, where the observability practices from the Spring course's production class apply unchanged. The Class 4 promise is then complete:
- Codes drive the dashboards.
- References let support locate the matching log entry.
- The logs retain the full exception.
- The response itself carries only the code and the reference.
Testing error responses
Everything this lesson wired up is behavior, and a test is what catches a regression in it. Spring for GraphQL's GraphQlTester, the harness the Spring course's testing class introduces, treats errors as first-class assertions, and its default posture is strict in the right direction. The testing reference states the default:
When you use
verify(), any errors under the "errors" key in the response will cause an assertion failure.
A test cannot read data past unexpected errors without deliberately dealing with them first. For the deliberate cases, the errors() API has three verbs:
filtersuppresses an error the test anticipated, so the rest of the response can still be asserted.expectasserts that a matching error is present, and fails otherwise.satisfyhands the whole list to aConsumerfor detailed assertions againstmessage,pathandextensions.
Inspecting errors any of these ways also marks them as filtered, so a subsequent verify() passes. Two tests show the pattern. The first checks that an unknown ID is an outcome, null with no error, the way Class 5 classified a stale ID. The second stubs the ratings client, a @MockitoBean in the test class, and checks the transient branch of TaxonomyExceptionResolver:
@Test
void unknownMovie_shouldReturnNullWithoutErrors() {
graphQlTester.document("query MissingMovie { movieById(id: \"999\") { title } }")
.execute()
.errors()
.verify()
.path("movieById")
.valueIsNull();
}
@Test
void ratingsOutage_shouldBeClassifiedUnavailable() {
given(ratingsClient.fetchRating("1")).willThrow(new RatingsUnavailableException(30));
graphQlTester.document("query MovieRatingDuringOutage { movieById(id: \"1\") { title rating } }")
.execute()
.errors()
.expect(error -> "UNAVAILABLE".equals(error.getExtensions().get("code")))
.verify()
.path("movieById.rating")
.valueIsNull();
}
Two guard tests complete the suite. The first raises an unmapped RuntimeException from a stubbed service and asserts that the resulting error carries the INTERNAL_ERROR classification and an opaque message. That locks in the masked fallback from the start of this class. If a later refactor ever catches and rewraps exceptions before they reach the resolver chain, this is the test that notices. The second asserts that Class 5's business outcomes arrive as data: a too-long review produces a populated userErrors list, a null review, and no errors entry at all, which proves outcomes are staying off the exception channel. Error responses are part of the API, and they deserve the same tests as the schema.
Elsewhere: DGS, Apollo Server, Hot Chocolate
Other frameworks divide up the same responsibilities described in this class; what differs between them is mainly the names of the hooks.
Netflix DGS is built on Spring for GraphQL, so most of this class transfers unchanged. Its documentation recommends @ControllerAdvice with @GraphQlExceptionHandler methods first, and the SubscriptionExceptionResolver it points at is Spring's own interface. What DGS adds is the typed convention Class 4 surveyed. Raise TypedGraphQLError instances, where builders such as newInternalErrorBuilder() set errorType, message, debugInfo and path. Or register a graphql-java DataFetcherExceptionHandler for custom mapping, delegating unknown cases to DefaultDataFetcherExceptionHandler exactly as our adapter returns null. See the DGS error handling docs; our Netflix DGS course, coming soon, has a hands-on error-handling class.
Apollo Server does the translation in resolvers, by raising GraphQLError with extensions.code. It centralizes response shaping in the formatError hook, which sees every error on its way out. Unexpected errors are tagged INTERNAL_SERVER_ERROR, and formatError is where message masking happens.
Hot Chocolate registers error filters through AddErrorFilter. Each filter receives an error and returns a changed copy, for example one rebuilt with ErrorBuilder.FromError(error) and given a code with SetCode. Exception detail stays hidden by default, as described above. Its mutation conventions generate Class 5-style payload and error types, so of the frameworks in this class it automates the outcomes-versus-failures split the most.
Errors across a federated graph
One deployment shape adds a hop to everything in this class: federation, where independent subgraph services each own part of the schema and a router plans queries across them and assembles each response. On the JVM, a subgraph is a Spring for GraphQL application using the federation-jvm integration (the reference's federation chapter covers @EntityMapping controllers, and @GraphQlExceptionHandler applies to their failures too). The resolver-chain approach above applies unchanged. What is new is that the subgraph's errors now travel to a router first, and the router has its own masking policy.
Apollo's GraphOS Router redacts subgraph errors by default: the client receives the message "Subgraph errors redacted" in place of the subgraph's own message, and the error's extensions are removed. The include_subgraph_errors section of router.yaml controls this, per the subgraph error inclusion documentation. Apollo recommends either full redaction or an allow list of the extension keys that clients may see:
include_subgraph_errors:
all:
redact_message: false
allow_extensions_keys:
- code
- service
With that configuration, an error keeps its message and its extensions.code, and it gains the subgraph's name under extensions.service. Setting redact_message: true instead keeps the code and hides the message. The router's redaction is the same security posture as Spring's masked INTERNAL_ERROR, one layer up, and the two layers have to be designed together. A subgraph that already classifies and masks per this class is safe to include, while full redaction in front of it discards the extensions.code the client was supposed to branch on.
Two more consequences follow. Class 4's vocabulary becomes an organizational contract in the strictest sense. A client can only branch on extensions.code if every subgraph team emits the same vocabulary and the router passes it through. That is exactly the fleet-standardization problem Class 11 shows Netflix solving, by baking the vocabulary into framework defaults. And Class 2's propagation now runs across team boundaries. The composed supergraph schema still contains Non-Null types, so a failing subgraph surfaces as errors that propagate through the assembled response. A non-null field served by one team can remove data another team's subgraph resolved successfully. A field that crosses a subgraph boundary crosses the clearest failure boundary an architecture can have. That gives Class 2's nullable-at-failure-boundaries guidance, and Class 8's @semanticNonNull bridge, more weight in a federated schema than anywhere else.
Summary
This lesson covered these parts of server-side error handling, from Spring for GraphQL's default to errors across a federated graph:
- The default. Spring for GraphQL's default is the right baseline: unresolved exceptions reach the client as an opaque
INTERNAL_ERRORcarrying only classification and anexecutionId, while the truth goes to the ERROR log. - The resolver chain. Customize through the ordered
DataFetcherExceptionResolverchain, withDataFetcherExceptionResolverAdapteras the base class. A single resolver,TaxonomyExceptionResolver, translates service exceptions into errors that carrypath-seeded context, anErrorTypeclassification, and a stableextensions.code. Returnnullfor anything unrecognized, so the masked default handles the persistent-failure quadrant. - Annotated handlers.
@GraphQlExceptionHandlerin a@ControllerAdviceis the idiomatic equivalent, and under Spring Boot it also covers other data fetchers and batch loaders. A catch-all handler claims controller exceptions before any resolver bean runs. - Batch loaders and subscriptions. Per-key
Tryresults, from aDataLoaderregistered outside@BatchMapping, prevent one bad record from failing a whole batch. Subscription streams need their ownSubscriptionExceptionResolverbecause the ordinary chain only sees the publisher's creation. - Outcomes and references. Business outcomes never ride the exception channel: controllers return Class 5's response types as values. Every masked failure keeps a reference, either the
executionIdor a shorter one returned inextensions. - Other frameworks. The same division of labor maps directly onto DGS (
TypedGraphQLError,DataFetcherExceptionHandler), Apollo Server (GraphQLErrorplusformatError), and Hot Chocolate (error filters, mutation conventions). - Tests.
GraphQlTesterfails on unexpected errors by default, andexpect/filter/satisfyhandle the deliberate ones. One test per resolver branch, plus a masked-fallback test and an outcomes-as-data test, keep the design from regressing. - Federation. The router redacts subgraph errors by default, and
include_subgraph_errorswith an allow list of extension keys passescodeandservicethrough. The code vocabulary has to be uniform across subgraphs to survive the trip, and error propagation crosses team boundaries.
Further Reading
- Spring for GraphQL reference, Exceptions: the resolver chain, the masked default, logging levels, and
SubscriptionExceptionResolver; the primary source for this class. - Spring for GraphQL reference, @GraphQlExceptionHandler: supported return types, scoping rules, and the sentence that explains when you need the lower-level resolver instead.
- Netflix DGS, Error handling:
TypedGraphQLError, the typed extensions structure, and DGS's exception handler contract. - Apollo Server, Error handling:
formatError, masking behavior, and stacktrace policy, the JavaScript mirror of everything this lesson built. - Hot Chocolate, Errors: error filters (
AddErrorFilter), the error-builder helpers, andGraphQLException; the mutation conventions that generate errors-as-data payloads automatically live on the mutations page linked from there. - java-dataloader documentation: the
Trymechanism for per-key batch failures and the default all-or-nothing batch semantics. - Spring for GraphQL reference, Testing: the
GraphQlTestererrors API (filter,expect,satisfy) and the strict default this class's tests rely on. - Spring for GraphQL reference, Federation: building a subgraph with
federation-jvmand@EntityMapping, including exception handling for entity resolution. - GraphOS Router, Subgraph error inclusion: the redaction default, the
include_subgraph_errorsconfiguration with its allow and deny lists for extension keys, and theextensions.serviceattribution.
What's Next
The server now emits classified failures and typed outcomes. Class 7 crosses the wire. It covers what Apollo Client, urql, and Relay each do with a response carrying both data and errors, why Relay's model is the strictest, and how a UI renders the sections that resolved while reporting the one that failed.