EzyHTTP fetcher implementation guide

Updated at 1783958059000
EzyHTTP GraphQL fetchers are the application-facing part of the GraphQL module. The runtime parses the query and dispatches it; the fetcher decides what data to load, how to validate arguments, which service method to call, and what DTO shape to return.
This guide assumes the GraphQL module is already enabled and focuses only on fetcher implementation. For the full request lifecycle, query parsing, dispatch, and response envelope, see the separate “EzyHTTP how GraphQL works” article.

Installation flow

Use this flow when adding a new GraphQL fetcher to an EzyHTTP application:
flowchart TD
    A[Enable GraphQL module] --> B[Create fetcher class]
    B --> C[Implement getData]
    C --> D[Declare query name]
    D --> E{Registration style}
    E --> F[Register as Ezy singleton]
    E --> G[Return from fetcher provider]
    F --> H[Add metadata if needed]
    G --> H
    H --> I[Add interceptor rules if needed]
    I --> J[Call query and verify response shape]
The usual path is simple: enable GraphQL, create a singleton fetcher, give it a query name, implement getData, and test it with a query that selects only the fields the client needs. Use a fetcher provider only when fetchers are dynamic or plugin-driven.

Where a fetcher begins

A fetcher is responsible for one top-level query field. If the client sends:
{
  profile(userId: 10) {
    id
    displayName
  }
}
EzyHTTP looks for the fetcher named profile. Once the fetcher is called, the request has already been parsed into a GraphQLQueryDefinition. That means fetcher code should not parse raw GraphQL strings, inspect HTTP methods, or build the final GraphQL response envelope. It should work at the application boundary:
request context + parsed query -> application service call -> response DTO

The fetcher contract

A fetcher implements one method:
Object getData(
    RequestArguments arguments,
    GraphQLQueryDefinition query
);
RequestArguments is the current EzyHTTP request context. It contains values prepared by the HTTP layer and interceptors, so a fetcher can reuse the same request-scoped data model as regular controllers.
GraphQLQueryDefinition is the parsed top-level query. It contains:
DataExample
Query nameprofile
Query argumentsprofile(userId: 10)
Selected fieldsid, displayName, friends
Nested field argumentsfriends(limit: 5)
The simplest implementation extends GraphQLAbstractDataFetcher:
@EzySingleton
public class ProfileDataFetcher extends GraphQLAbstractDataFetcher {

    private final ProfileService profileService;

    public ProfileDataFetcher(ProfileService profileService) {
        this.profileService = profileService;
    }

    @Override
    public ProfileResponse getData(
        RequestArguments arguments,
        GraphQLQueryDefinition query
    ) {
        Long userId = query.getArgumentValue("userId", Long.class);
        return profileService.getProfile(userId);
    }

    @Override
    public String getQueryName() {
        return "profile";
    }
}
The important boundary is the method body: read what you need from query and arguments, call your service layer, and return a response object. Keep transport concerns outside the fetcher.

Naming a fetcher

EzyHTTP resolves the fetcher name in one of two ways.
The first option is to override getQueryName():
@Override
public String getQueryName() {
    return "profile";
}
The second option is to annotate the fetcher:
@GraphQLQuery("profile")
@EzySingleton
public class ProfileDataFetcher extends GraphQLAbstractDataFetcher {
    // ...
}
@GraphQLQuery supports both value and name; name takes precedence when both are present. It also supports group, which is useful for organizing queries:
@GraphQLQuery(name = "profile", group = "user")
@EzySingleton
public class ProfileDataFetcher extends GraphQLAbstractDataFetcher {
    // ...
}
If neither getQueryName() nor @GraphQLQuery provides a name, the default implementation throws an error during registration or use. In practice, every fetcher should declare its query name explicitly.

Registering fetchers

For normal application fetchers, register them as Ezy singletons:
@EzySingleton
public class ProfileDataFetcher extends GraphQLAbstractDataFetcher {
    // ...
}
When GraphQL is enabled, EzyHTTP discovers these singletons and adds them to the fetcher manager. The manager stores the mapping:
query name -> fetcher instance
It also stores optional metadata:
MetadataSource
Query groupgetQueryGroupName() or @GraphQLQuery(group = "...")
Authenticated query@Authenticated or AuthenticatedController
Management query@EzyManagement, ManagementController, or ManageableController
Payment query@EzyPayment or PaymentController
If a fetcher does not declare a group, EzyHTTP puts it in the default group.

Dynamic fetchers

Use GraphQLDataFetcherProvider when the fetcher cannot or should not be registered eagerly.
@EzySingleton
public class DynamicFetcherProvider implements GraphQLDataFetcherProvider {

    private final ProfileService profileService;

    public DynamicFetcherProvider(ProfileService profileService) {
        this.profileService = profileService;
    }

    @Override
    public GraphQLDataFetcher provide(String queryName) {
        if ("profile".equals(queryName)) {
            return new ProfileDataFetcher(profileService);
        }
        return null;
    }
}
The fetcher manager checks registered fetchers first. If no fetcher is found for the query name, it asks each provider. A provider should return null when it does not own the query.
Prefer regular singleton fetchers for most APIs. Providers are best for plugin-style modules, tenant-specific query sets, or generated fetchers.

Reading query arguments

Use getArgumentValue to read arguments on the top-level query:
Long userId = query.getArgumentValue("userId", Long.class);
String locale = query.getArgumentValue("locale", String.class);
Integer limit = query.getArgumentValue("limit", Integer.class);
The typed overload converts strings and mapped values to the requested Java type where possible. If the argument is absent, it returns null.
Example query:
{
  profile(userId: 10, locale: "en") {
    id
    displayName
  }
}
For optional arguments, set application-level defaults in the fetcher:
Integer limit = query.getArgumentValue("limit", Integer.class);
if (limit == null) {
    limit = 20;
}
Keep validation close to the fetcher or service boundary. The GraphQL layer parses and converts arguments, but your fetcher still owns business constraints such as maximum page size, required identifiers, and allowed enum values.

Validating arguments

Treat parsed arguments as input, not as trusted business data. A practical fetcher usually validates three things before calling the service layer:
CheckExample
Required valueuserId must not be null
Rangelimit must be between 1 and 100
RelationshipThe current user can read the requested profile
For simple validation, fail early:
Long userId = query.getArgumentValue("userId", Long.class);
if (userId == null) {
    throw GraphQLFetcherException.builder()
        .error(
            GraphQLError.builder()
                .message("userId is required")
                .path("profile", "userId")
                .build()
        )
        .build();
}
For business validation, delegate to the service layer and translate known domain failures into client-facing GraphQL errors only when the client can act on them. Unexpected exceptions should still be logged and handled by the normal server error flow.

Reading nested field arguments

Nested selected fields can have their own arguments:
{
  profile(userId: 10) {
    id
    posts(limit: 5) {
      title
      createdAt
    }
  }
}
The fetcher can read them through the query definition:
Integer postLimit = query.getFieldArgumentValue(
    "limit",
    Integer.class,
    "posts"
);
This is useful when one top-level fetcher owns a data graph but wants client-provided options for nested parts. It also lets the fetcher avoid unnecessary work:
boolean includePosts = query.getField("posts") != null;
If posts is not selected, the fetcher can skip loading posts entirely.

Response shape rules

A fetcher should return an object that can be converted to a map by EzyHTTP's object mapper. DTOs, JavaBeans, Lombok-backed response objects, and maps are good fits.
Design those DTOs around the GraphQL fields you want clients to request:
@Getter
@Builder
public class ProfileResponse {
    private final long id;
    private final String displayName;
    private final String avatarUrl;
    private final List<PostResponse> posts;
}
Avoid returning a DTO whose property names are implementation-oriented, such as database column names or internal aggregate names, unless those names are meant to be part of the public GraphQL contract.
For nested selections, nested values must also be map-like after conversion. Lists should contain objects or maps, not scalar values, when the query selects subfields. This is a good match:
{
  profile(userId: 10) {
    posts {
      title
    }
  }
}
return ProfileResponse.builder()
    .id(10L)
    .posts(List.of(new PostResponse("GraphQL in EzyHTTP")))
    .build();
But if posts is a List<String> and the query asks for posts { title }, the response shape is invalid because EzyHTTP expects each list item to be map-like for nested filtering.
When you need to expose a computed field, compute it in the DTO or assembler layer before returning:
return ProfileResponse.builder()
    .id(user.getId())
    .displayName(user.getFirstName() + " " + user.getLastName())
    .avatarUrl(imageUrlFactory.create(user.getAvatarPath()))
    .build();
For internal tools, EzyHTTP also supports the special * selection to include all non-null fields from a map-like object. Public APIs should normally prefer explicit fields, because it makes the contract easier to evolve.

Handling request context

Because fetchers receive RequestArguments, they can reuse request-scoped values prepared by EzyHTTP interceptors.
Long currentUserId = arguments.getArgument("currentUserId");
This is a good pattern for authenticated GraphQL APIs:
@Authenticated
@GraphQLQuery(name = "viewer", group = "user")
@EzySingleton
public class ViewerDataFetcher extends GraphQLAbstractDataFetcher {

    @Override
    public ViewerResponse getData(
        RequestArguments arguments,
        GraphQLQueryDefinition query
    ) {
        Long userId = arguments.getArgument("currentUserId");
        return userService.getViewer(userId);
    }
}
The fetcher does not need to parse tokens or read headers directly. That work belongs in normal request infrastructure or GraphQL interceptors.

Keeping cross-cutting code out of fetchers

Fetchers should stay focused on data loading. Shared concerns such as access checks, query logging, request enrichment, and feature gating are better placed in GraphQLInterceptor.
@EzySingleton
public class QueryAccessInterceptor implements GraphQLInterceptor {

    @Override
    public boolean preHandle(
        RequestArguments arguments,
        String queryGroup,
        String queryName,
        GraphQLQueryDefinition queryDefinition,
        GraphQLDataFetcher dataFetcher
    ) {
        // return false to reject this query
        return true;
    }
}
Interceptors are sorted by getPriority() in ascending order. Returning false from preHandle rejects the current query. Use that for shared decisions; use fetcher code for query-specific validation and service calls.
This keeps fetchers easy to test. A fetcher test can provide a prepared RequestArguments object and a parsed query definition, then assert that the correct service call is made.

Reporting errors

If no fetcher is found for a query name, EzyHTTP returns a GraphQL-style error response. Fetchers can also throw GraphQLFetcherException when they need to return structured GraphQL errors:
throw GraphQLFetcherException.builder()
    .error(
        GraphQLError.builder()
            .message("profile not found")
            .build()
    )
    .build();
Use this for client-facing GraphQL errors. For unexpected internal failures, it is usually better to let the normal exception flow handle logging and response mapping, unless you need a specific GraphQL error payload.

Implementation checklist

Use this checklist when adding a new fetcher:
CheckWhy it matters
Declare a query nameThe dispatcher needs a stable top-level GraphQL field name
Register as singletonThe runtime discovers fetchers from the application container
Return DTOs or mapsThe response is converted to a map before field filtering
Validate argumentsParsing is not the same as business validation
Inspect selected fields when usefulAvoid loading expensive nested data that was not requested
Keep auth outside data loadingUse request infrastructure and interceptors for cross-cutting rules
Throw structured errors deliberatelyClient-facing GraphQL errors should be predictable

Common mistakes

Missing query name is the most basic mistake. If a fetcher has neither getQueryName() nor @GraphQLQuery, EzyHTTP cannot dispatch to it.
Returning scalar data for a nested selection is another common issue. A scalar can work for a simple value-style query, but nested field filtering expects map-like data.
Loading every relationship unconditionally can make a fetcher expensive. The query definition tells you which fields were requested, so use it when a nested field requires extra database or network calls.
Putting authentication logic directly in every fetcher also tends to become repetitive. Prefer regular request authentication, @Authenticated metadata, and GraphQLInterceptor for shared access decisions.

Conclusion

An EzyHTTP GraphQL fetcher should be small, named clearly, registered as a singleton, and focused on one top-level query. It reads typed arguments from GraphQLQueryDefinition, uses RequestArguments for request-scoped context, returns DTOs or maps, and lets EzyHTTP filter the response to the client's selected fields.
That design keeps GraphQL support lightweight: fetchers stay close to application services, while the framework handles parsing, dispatch, interception, response shaping, and GraphQL-style error payloads.

Table Of Contents