GraphQL for Java Developers: What You Actually Need to Know

GraphQL has been on the Java back-end radar for a while, mostly as something the front-end team kept bringing up. In 2022 that shifted. Spring for GraphQL 1.0 became generally available in May. The official Spring team now provides first-party GraphQL integration built on graphql-java, making GraphQL endpoints easier to adopt and evaluate. 🧩

This article is a tour of GraphQL for Java developers — what holds up in practice, what is oversold, and what the Spring side looks like in code.

One Endpoint, Client-Driven Queries

The headline feature of GraphQL is that clients dictate the exact shape of the data they want, and they do it through a single endpoint — almost always /graphql. No more /users/123, /users/123/orders, /users/123/orders/recent; one URL, one POST per request, the query body describes everything.

The wire format looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# The Query
query {
  user(id: "1") {
    name
    email
  }
}

# The Response
{
  "data": {
    "user": {
      "name": "Alex",
      "email": "alex@example.com"
    }
  }
}

The response mirrors the query exactly. If you wanted only the name, you’d ask for only the name and the response would carry only the name. That eliminates two long-running REST headaches in one move: over-fetching (the API gives you fifteen fields you don’t need) and under-fetching (you have to chain three calls to assemble the page).

The Schema is the Contract

Every GraphQL Application Programming Interface (API) is described by a Schema Definition Language (SDL) document — the strict, machine-checked blueprint of every type, every field, every argument. The server refuses to execute a query that names a field the schema doesn’t declare. No more reading documentation and crossing your fingers. ✍️

With Spring for GraphQL, you drop your schema into src/main/resources/graphql/schema.graphqls:

1
2
3
4
5
6
7
8
9
type Query {
  userById(id: ID!): User
}

type User {
  id: ID!
  name: String!
  email: String!
}

The exclamation marks mean “non-null” — Spring for GraphQL maps that straight onto Java’s nullability story, and the schema is loaded at startup with full validation. Typos fail fast. The other consequence of this strong typing is that the response shape mirrors the query exactly, which is wonderful for front-end developers — they can predict the response payload just by reading their own code.

Three Operation Types

GraphQL splits all interactions into three first-class operations:

  • Queries — read-only data fetches.
  • Mutations — writes (create, update, delete).
  • Subscriptions — long-lived streams of events pushed from server to client.

You’ll see a lot of articles claim subscriptions “are delivered over WebSockets.” That’s the most common transport, but it’s not the only one — the graphql-sse spec (Server-Sent Events) is a perfectly valid alternative if you don’t want to drag a WebSocket stack into your service. ⚠️ The point is: subscriptions are an operation type, not a transport. Pick the transport that fits your infrastructure.

Resolvers and the N+1 Trap

On the server, every field is backed by a function called a resolver. The runtime walks the query tree, calling the resolver for each requested field. Resolvers are where you connect GraphQL to your actual data — a Java Persistence API (JPA) repository, a downstream Representational State Transfer (REST) call, a Redis lookup, whatever.

In Spring for GraphQL, resolvers are just @Controller beans with the right annotations: 🔌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
public class UserController {

    private final UserRepository users;

    public UserController(UserRepository users) {
        this.users = users;
    }

    @QueryMapping
    public User userById(@Argument String id) {
        return users.findById(Long.parseLong(id)).orElse(null);
    }
}

That maps the SDL field Query.userById to a Java method. Spring for GraphQL handles the dispatch, the argument binding, and the JavaScript Object Notation (JSON) serialization.

The trap waiting for you: N+1 queries. If a client asks for a list of 50 users and each user’s orders field is resolved independently, you’ll hit your database 51 times — once for the user list, then once per user for their orders. The classic fix in the GraphQL world is DataLoader: a batching, caching shim that collects all the individual userId requests within a single execution and flushes them as one batched database call. Spring for GraphQL wraps this with @BatchMapping, which is the most ergonomic version I’ve used:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
public class UserController {

    private final OrderRepository orders;

    @BatchMapping(typeName = "User")
    public Map<User, List<Order>> orders(List<User> users) {
        List<Long> ids = users.stream().map(User::getId).toList();
        Map<Long, List<Order>> byUser = orders.findAllByUserIdIn(ids)
                .stream()
                .collect(Collectors.groupingBy(Order::getUserId));
        return users.stream()
                .collect(Collectors.toMap(u -> u, u -> byUser.getOrDefault(u.getId(), List.of())));
    }
}

One database call for any number of users in the query. The java-dataloader library is doing the heavy lifting under the hood; you just don’t have to think about it.

Introspection: Great in Dev, Risky in Prod

One of the genuinely lovely things about GraphQL is introspection — the schema can be queried for its own structure. That’s what powers tools like GraphiQL and Apollo Studio: you point them at the endpoint, they pull the schema, and you get auto-complete, inline docs, and a query playground for free.

The caveat that doesn’t make it into the listicles: most teams disable introspection in production. A public introspection endpoint hands attackers a free map of every field, every mutation, every internal type name. graphql-java exposes a flag to turn it off, and Spring for GraphQL exposes the same via configuration. Leave it on in dev and staging; turn it off (or gate it behind auth) in production. 🛡️

“Versionless” is a Goal, Not a Guarantee

You’ll read that GraphQL APIs are “versionless” — that you evolve continuously, add fields, mark old ones with @deprecated, and never have to ship a /v2/ path. The first half of that is true; the second half is a bit of a fairy tale.

GraphQL gives you genuinely good tools for additive evolution. Adding a field never breaks anyone — old clients don’t ask for it, new clients do. @deprecated shows up in introspection and in IDE tooling, which is more visible than a JIRA ticket. But you can absolutely ship breaking changes: remove a field, narrow an enum, change a nullable field to non-null, rename a type. Any of those will break clients in production. The discipline of versionless evolution is just that — a discipline. The schema doesn’t enforce it for you.

The thing GraphQL really gives you is observability of usage. Because every query names the fields it touches, you can log queries and see exactly which clients depend on which fields. Combine that with deprecation tooling and you can retire fields safely — but it’s still work, not magic.

Errors and Status Codes

This one trips up everyone on day one: in classic GraphQL, almost every response returns Hypertext Transfer Protocol (HTTP) 200 OK, even when the operation failed. Errors come back in a dedicated errors array inside the JSON body, not as a 4xx or 5xx.

1
2
3
4
5
6
7
8
9
10
{
  "data": null,
  "errors": [
    {
      "message": "User not found",
      "path": ["userById"],
      "extensions": { "classification": "NOT_FOUND" }
    }
  ]
}

This is jarring if you’ve spent years writing Spring @ExceptionHandlers that map exceptions to 4xx/5xx. But it’s intentional — the transport (HTTP) succeeded; the application semantics (the query) had a problem, and that’s a different layer. The newer GraphQL-over-HTTP draft spec does loosen this and allow proper status codes when the response uses application/graphql-response+json, but most clients in the wild still expect the old convention. Code for 200 + errors array first; you can adopt the newer behavior later.

So, Is It Worth It?

GraphQL earns its keep when your front end is genuinely composite — a single page assembling data from five backend domains — and when you have clients you don’t fully control (mobile apps shipped to app stores, partner integrations). It’s a poor fit for simple Create, Read, Update, Delete (CRUD) services with one consumer; you’ll spend more time wiring resolvers than you’d ever spend writing REST controllers.

For Java teams in 2022, the calculus has genuinely shifted. Spring for GraphQL turns the integration cost from “weekend project” into “afternoon project,” and @BatchMapping handles the most common performance footgun out of the box. If you’ve been waiting for the right moment to put GraphQL in front of a Spring Boot service, this is the moment. 🎯

Further Reading

  • Spring for GraphQL documentationdocs.spring.io/spring-graphql. The reference is genuinely good; start with the “Controllers” and “Batch Loading” sections.
  • graphql-javagraphql-java.com. The underlying engine; you’ll only touch it directly for advanced customization.
  • The GraphQL specificationspec.graphql.org. Surprisingly readable. The “Validation” and “Execution” sections are worth the hour.
  • Production Ready GraphQL (Marc-André Giroux) — the best single book on operating GraphQL at scale.
  • GraphQL-over-HTTP working draftgithub.com/graphql/graphql-over-http. Where the status code and transport conventions are being formalized.
This entry was posted in java and tagged , , , . Bookmark the permalink.

Comments are closed.