Schema-First GraphQL on Quarkus Without Losing Control of the Contract
Use SDL-first design, explicit router wiring, and GraphQL Java when a team needs a stable API contract and lower-level request control than code-first GraphQL provides.
I like quarkus-smallrye-graphql when Java should own the API. You add annotations, Quarkus generates the schema, and the happy path stays small. That works when the contract belongs to the application.
It breaks down when the schema already exists before the Java code does. Maybe a frontend team owns the contract. Maybe another service already speaks GraphQL and you are matching it. Maybe you need custom GraphQL Java wiring, direct access to the Vert.x routing context, or WebSocket subscriptions on your own terms. At that point, annotations stop helping because the abstraction is wrong.
quarkus-vertx-graphql is a thin Quarkus extension around Vert.x Web GraphQL. It does not generate a GraphQL API for you. It gives you the Quarkus packaging, the GraphiQL UI at /q/graphql-ui in dev and test, and the GraphQL WebSocket protocol wiring. You build the schema and handlers yourself. That is where the value is.
We build that setup end to end in a small shipment-tracking service. The schema lives in SDL first. Queries are scoped by an X-Warehouse-Code header. A mutation updates shipment status. A subscription pushes live changes over WebSocket. The app stays tiny on purpose because the point is owning the contract and the GraphQL runtime, not wiring a database.
If you are happy with MicroProfile GraphQL annotations and code-first schema generation, use SmallRye GraphQL on Quarkus. This article covers the other case: schema-first, GraphQL Java, and direct Vert.x control.
Prerequisites
This walkthrough uses Quarkus 3.36.1, Java 21, and the quarkus-vertx-graphql extension. You should already know basic Quarkus dev mode, basic GraphQL query syntax, and how to run a JSON POST request from a terminal.
JDK 21 installed
Quarkus CLI or Maven 3.9+
Basic understanding of Quarkus and GraphQL
About ☕️☕️
Project setup
Create the application or start from my Github repository:
quarkus create app com.themainthread:shipment-schema-first \
--extension='vertx-graphql' \
--java=21 \
--no-codeThat gave me a Quarkus project with these dependencies in pom.xml:
quarkus-vertx-graphql— Vert.x Web GraphQL integration, GraphiQL UI, and GraphQL WebSocket protocol registrationquarkus-arc— CDI, because we produce aGraphQLinstance and observe the Vert.x router at startup
There is no generated GreetingResource, which is what we want. There are enough moving parts already without a leftover REST endpoint taking up space.
Under src/main/java, create com/themainthread/shipment/. Under src/main/resources, create graphql/.
Start with the contract
Before writing Java, write the schema you actually want. The SDL comes first, and Java implements what the SDL defines.
Create src/main/resources/graphql/shipment.graphqls:
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
type Query {
viewerWarehouse: String!
shipments(status: ShipmentStatus): [Shipment!]!
shipment(id: ID!): Shipment
}
type Mutation {
updateShipmentStatus(id: ID!, status: ShipmentStatus!): Shipment!
}
type Subscription {
shipmentUpdates(id: ID!): Shipment!
}
type Shipment {
id: ID!
description: String!
destinationCity: String!
warehouseCode: String!
status: ShipmentStatus!
}
enum ShipmentStatus {
CREATED
PICKED
IN_TRANSIT
DELAYED
DELIVERED
}Notice what is missing here: no Java package names, no CDI annotations, no framework-specific types. The SDL defines what the API looks like. Java wires to it afterward.
Model the data
The runtime model is small. We only need a Shipment record and a ShipmentStatus enum.
Create src/main/java/com/themainthread/shipment/ShipmentStatus.java:
package com.themainthread.shipment;
public enum ShipmentStatus {
CREATED,
PICKED,
IN_TRANSIT,
DELAYED,
DELIVERED
}Create src/main/java/com/themainthread/shipment/Shipment.java:
package com.themainthread.shipment;
public record Shipment(
String id,
String description,
String destinationCity,
String warehouseCode,
ShipmentStatus status) {
}Nothing clever here yet, and that is fine for now.
Seed a tiny shipment store
We need something the GraphQL layer can talk to. I am using an in-memory store because the tutorial is about schema ownership and routing. Wiring a database before the first query works would just slow things down.
Create src/main/java/com/themainthread/shipment/ShipmentStore.java:
package com.themainthread.shipment;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import io.quarkus.runtime.StartupEvent;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.subscription.MultiEmitter;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
@ApplicationScoped
public class ShipmentStore {
public static final String DEFAULT_WAREHOUSE = "BER";
private final Map<String, Shipment> shipments = new ConcurrentHashMap<>();
private final List<Subscription> subscriptions = new CopyOnWriteArrayList<>();
void seed(@Observes StartupEvent event) {
shipments.put("BER-1001", new Shipment(
"BER-1001",
"Laptop spare parts for the Berlin repair hub",
"Berlin",
"BER",
ShipmentStatus.CREATED));
shipments.put("BER-1002", new Shipment(
"BER-1002",
"Priority passport package for the embassy desk",
"Berlin",
"BER",
ShipmentStatus.IN_TRANSIT));
shipments.put("AMS-2001", new Shipment(
"AMS-2001",
"Cold-chain insulin refill for Amsterdam clinic",
"Amsterdam",
"AMS",
ShipmentStatus.PICKED));
}
public List<Shipment> listShipments(String warehouseCode, ShipmentStatus status) {
List<Shipment> visible = new ArrayList<>();
for (Shipment shipment : shipments.values()) {
if (!shipment.warehouseCode().equals(warehouseCode)) {
continue;
}
if (status != null && shipment.status() != status) {
continue;
}
visible.add(shipment);
}
return visible;
}
public Shipment findShipment(String id, String warehouseCode) {
Shipment shipment = shipments.get(id);
if (shipment == null || !shipment.warehouseCode().equals(warehouseCode)) {
return null;
}
return shipment;
}
public Shipment updateStatus(String id, ShipmentStatus status, String warehouseCode) {
Shipment current = findShipment(id, warehouseCode);
if (current == null) {
throw new NoSuchElementException("Shipment " + id + " is not visible from warehouse " + warehouseCode);
}
Shipment updated = new Shipment(
current.id(),
current.description(),
current.destinationCity(),
current.warehouseCode(),
status);
shipments.put(id, updated);
broadcast(updated);
return updated;
}
public Multi<Shipment> shipmentUpdates(String id) {
return Multi.createFrom().<Shipment> emitter(emitter -> {
Subscription subscription = new Subscription(id, emitter);
subscriptions.add(subscription);
emitter.onTermination(() -> subscriptions.remove(subscription));
});
}
private void broadcast(Shipment shipment) {
for (Subscription subscription : subscriptions) {
if (subscription.shipmentId.equals(shipment.id())) {
subscription.emitter.emit(shipment);
}
}
}
private static final class Subscription {
private final String shipmentId;
private final MultiEmitter<? super Shipment> emitter;
private Subscription(String shipmentId, MultiEmitter<? super Shipment> emitter) {
this.shipmentId = shipmentId;
this.emitter = emitter;
}
}
}Two decisions matter here.
First, the store knows nothing about HTTP headers. It only takes a warehouse code string. That keeps the storage layer clean. Request scoping happens at the GraphQL boundary, where it belongs.
Second, the subscription path returns a Multi<Shipment>. Mutiny Multi implements the Reactive Streams Publisher contract that GraphQL subscriptions need, so we do not need another streaming library here.
Build GraphQL Java from the SDL
We connect the SDL to real fetchers next.
Create src/main/java/com/themainthread/shipment/ShipmentGraphQLProducer.java:
package com.themainthread.shipment;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.function.Predicate;
import graphql.GraphQL;
import graphql.schema.DataFetcher;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import graphql.schema.idl.TypeRuntimeWiring;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.graphql.GraphQLHandler;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton;
@ApplicationScoped
public class ShipmentGraphQLProducer {
private final ShipmentStore shipmentStore;
ShipmentGraphQLProducer(ShipmentStore shipmentStore) {
this.shipmentStore = shipmentStore;
}
@Produces
@Singleton
GraphQL graphQL() {
TypeDefinitionRegistry typeRegistry = new SchemaParser().parse(loadSchema());
RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring()
.type(TypeRuntimeWiring.newTypeWiring("Query")
.dataFetcher("viewerWarehouse", viewerWarehouse())
.dataFetcher("shipments", shipments())
.dataFetcher("shipment", shipment()))
.type(TypeRuntimeWiring.newTypeWiring("Mutation")
.dataFetcher("updateShipmentStatus", updateShipmentStatus()))
.type(TypeRuntimeWiring.newTypeWiring("Subscription")
.dataFetcher("shipmentUpdates", shipmentUpdates()))
.build();
GraphQLSchema schema = new SchemaGenerator().makeExecutableSchema(typeRegistry, wiring);
return GraphQL.newGraphQL(schema).build();
}
private DataFetcher<String> viewerWarehouse() {
return environment -> currentWarehouse(environment);
}
private DataFetcher<Object> shipments() {
return environment -> shipmentStore.listShipments(
currentWarehouse(environment),
statusArgument(environment, "status"));
}
private DataFetcher<Shipment> shipment() {
return environment -> shipmentStore.findShipment(
environment.getArgument("id"),
currentWarehouse(environment));
}
private DataFetcher<Shipment> updateShipmentStatus() {
return environment -> shipmentStore.updateStatus(
environment.getArgument("id"),
statusArgument(environment, "status"),
currentWarehouse(environment));
}
private DataFetcher<Object> shipmentUpdates() {
return environment -> shipmentStore.shipmentUpdates(environment.getArgument("id"));
}
private String currentWarehouse(graphql.schema.DataFetchingEnvironment environment) {
RoutingContext routingContext = GraphQLHandler.getRoutingContext(environment.getGraphQlContext());
if (routingContext == null) {
return ShipmentStore.DEFAULT_WAREHOUSE;
}
return Optional.ofNullable(routingContext.request().getHeader("X-Warehouse-Code"))
.map(String::trim)
.filter(Predicate.not(String::isEmpty))
.orElse(ShipmentStore.DEFAULT_WAREHOUSE);
}
private ShipmentStatus statusArgument(graphql.schema.DataFetchingEnvironment environment, String name) {
Object value = environment.getArgument(name);
if (value == null) {
return null;
}
if (value instanceof ShipmentStatus shipmentStatus) {
return shipmentStatus;
}
return ShipmentStatus.valueOf(value.toString());
}
private String loadSchema() {
try (InputStream inputStream = Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream("graphql/shipment.graphqls")) {
if (inputStream == null) {
throw new IllegalStateException("Could not load graphql/shipment.graphqls");
}
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}We load the SDL, parse it, attach fetchers by field name, and build a GraphQL instance ourselves. The SDL stays the contract. Java wires to it. No annotation generates the schema from field names.
The currentWarehouse(...) helper is the other reason I like this setup. Vert.x Web GraphQL puts the RoutingContext into GraphQL context for HTTP requests, so a fetcher can read the real request header directly. If your GraphQL boundary needs tenant headers, user context, or request metadata, you just read it from the routing context. No REST filter layer needed.
There is a smaller schema-first detail in statusArgument(...) too: enum input arrives as a plain value, so we normalize it with ShipmentStatus.valueOf(...). You do more wiring at this level. That is the trade-off, and I think it is a fair one.
What happens when a request does not send X-Warehouse-Code? The fetcher falls back to BER, so the default query view shows the Berlin warehouse only.
Register /graphql on the Vert.x router
We wire the actual endpoint next.
Create src/main/java/com/themainthread/shipment/ShipmentGraphQLRoutes.java:
package com.themainthread.shipment;
import graphql.GraphQL;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.handler.BodyHandler;
import io.vertx.ext.web.handler.graphql.GraphQLHandler;
import io.vertx.ext.web.handler.graphql.ws.GraphQLWSHandler;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
@ApplicationScoped
public class ShipmentGraphQLRoutes {
void register(@Observes Router router, GraphQL graphQL) {
router.post("/graphql")
.handler(BodyHandler.create());
router.route("/graphql")
.handler(GraphQLWSHandler.create(graphQL));
router.route("/graphql")
.handler(GraphQLHandler.create(graphQL));
}
}Three details:
BodyHandlerhas to run forPOST /graphqlbecause the GraphQL HTTP handler needs the parsed request bodyGraphQLWSHandlerhas to be registered beforeGraphQLHandleron the same URI because Vert.x GraphQL over WebSocket needs the protocol upgrade path firstthe
quarkus-vertx-graphqlextension already handles the GraphQL WebSocket subprotocol registration and GraphiQL UI, so the class stays small
One easy mistake: putting BodyHandler after a user handler on the same route chain fails startup. I lost 20 minutes to that once.
Router access comes from the Quarkus HTTP layer (reactive routes guide). The handler ordering is documented in the Vert.x Web GraphQL reference.
GraphiQL and configuration
This demo does not need any application.properties entries. The extension serves GraphiQL at /q/graphql-ui/ in dev and test by default.
If you want to include the UI outside dev and test, add this:
quarkus.vertx-graphql.ui.always-include=trueYou can also change the UI path:
quarkus.vertx-graphql.ui.path=graphql-uiThat value is relative to the Quarkus non-application root, so the default UI URL is still /q/graphql-ui/. Do not set the path to /. The extension refuses it because it would block everything else.
Run the first query
Start dev mode:
cd shipment-schema-first
./mvnw quarkus:devOpen http://localhost:8080/q/graphql-ui/ and run:
query {
viewerWarehouse
shipments {
id
destinationCity
warehouseCode
status
}
}You should see Berlin as the default warehouse and two Berlin shipments:
{
"data": {
"viewerWarehouse": "BER",
"shipments": [
{
"id": "BER-1001",
"destinationCity": "Berlin",
"warehouseCode": "BER",
"status": "CREATED"
},
{
"id": "BER-1002",
"destinationCity": "Berlin",
"warehouseCode": "BER",
"status": "IN_TRANSIT"
}
]
}
}The schema came from the SDL, but the request still sees real HTTP context through Vert.x. That is the combination we wanted.
Change the warehouse without changing the schema
Same query, different warehouse header:
curl -s http://localhost:8080/graphql \
-H 'Content-Type: application/json' \
-H 'X-Warehouse-Code: AMS' \
-d '{"query":"query { viewerWarehouse shipments { id warehouseCode status } }"}'Expected result:
{
"data": {
"viewerWarehouse": "AMS",
"shipments": [
{
"id": "AMS-2001",
"warehouseCode": "AMS",
"status": "PICKED"
}
]
}
}We did not change the SDL and we did not add a REST filter. The warehouse switch happened at the GraphQL boundary through a request header, with the same schema contract serving both cases.
Run the mutation
Update one shipment via GraphiQL:
mutation {
updateShipmentStatus(id: "BER-1001", status: DELIVERED) {
id
status
}
}Expected result:
{
"data": {
"updateShipmentStatus": {
"id": "BER-1001",
"status": "DELIVERED"
}
}
}Query it again:
query {
shipment(id: "BER-1001") {
id
status
}
}You should now get:
{
"data": {
"shipment": {
"id": "BER-1001",
"status": "DELIVERED"
}
}
}The store update path, schema wiring, and HTTP route are all connected now. Next we use the WebSocket handler we registered earlier.
Add the live subscription
Open a GraphiQL tab and run:
subscription {
shipmentUpdates(id: "BER-1001") {
id
status
}
}In another terminal, trigger a mutation:
curl -s http://localhost:8080/graphql \
-H 'Content-Type: application/json' \
-H 'X-Warehouse-Code: BER' \
-d '{"query":"mutation { updateShipmentStatus(id: \"BER-1001\", status: IN_TRANSIT) { id status } }"}'The subscription tab receives a pushed event for BER-1001.
We did not add another Quarkus extension for this. We wrote the subscription field in SDL, returned a Reactive Streams publisher from the store, and registered GraphQLWSHandler before GraphQLHandler on /graphql. The Quarkus extension handles the WebSocket protocol support, so the lower-level wiring stays short.
Prove it with tests
We add one integration-style test that hits the real GraphQL endpoint.
Create src/test/java/com/themainthread/shipment/ShipmentGraphQLTest.java:
package com.themainthread.shipment;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.notNullValue;
import java.util.Map;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import static io.restassured.RestAssured.given;
@QuarkusTest
class ShipmentGraphQLTest {
@Test
void shouldUseTheDefaultWarehouseWhenNoHeaderIsPresent() {
given()
.contentType(ContentType.JSON)
.body(graphql("""
query {
viewerWarehouse
shipments {
id
warehouseCode
}
}
"""))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.viewerWarehouse", equalTo("BER"))
.body("data.shipments.id", hasItems("BER-1001", "BER-1002"))
.body("data.shipments.warehouseCode", hasItems("BER"));
}
@Test
void shouldFilterShipmentsByWarehouseHeader() {
given()
.header("X-Warehouse-Code", "AMS")
.contentType(ContentType.JSON)
.body(graphql("""
query {
viewerWarehouse
shipments {
id
warehouseCode
status
}
}
"""))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.viewerWarehouse", equalTo("AMS"))
.body("data.shipments.size()", equalTo(1))
.body("data.shipments[0].id", equalTo("AMS-2001"))
.body("data.shipments[0].status", equalTo("PICKED"));
}
@Test
void shouldUpdateStatusThroughTheMutation() {
given()
.header("X-Warehouse-Code", "BER")
.contentType(ContentType.JSON)
.body(graphql("""
mutation {
updateShipmentStatus(id: "BER-1001", status: DELIVERED) {
id
status
}
}
"""))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.updateShipmentStatus.id", equalTo("BER-1001"))
.body("data.updateShipmentStatus.status", equalTo("DELIVERED"));
given()
.contentType(ContentType.JSON)
.body(graphql("""
query {
shipment(id: "BER-1001") {
id
status
}
}
"""))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.shipment.status", equalTo("DELIVERED"));
}
@Test
void shouldExposeGraphiqlThroughTheExtension() {
given()
.when()
.get("/q/graphql-ui")
.then()
.statusCode(200)
.body(notNullValue());
}
private Map<String, String> graphql(String query) {
return Map.of("query", query);
}
}Run them:
./mvnw testFour passing tests. They cover default warehouse scope, header-based warehouse override, the mutation path, and GraphiQL availability.
When this approach is the right one
This lower-level stack fits better than SmallRye GraphQL when:
the schema already exists and Java should implement it
you need direct GraphQL Java wiring or explicit fetcher control
request context from Vert.x matters at the GraphQL layer
you want to control HTTP and WebSocket handler order yourself
If all you need is a clean code-first GraphQL API with MicroProfile annotations, use SmallRye. More wiring than the problem needs is just more wiring.
Production hardening
The demo is small, but you can already see the real risks:
The in-memory store and emitter list are single-node only. A real service needs proper persistence and event fan-out.
X-Warehouse-Codeis request scoping, not authorization. If that value matters for security, add authentication and authorization in front of it.Route order matters.
BodyHandlerhas to parsePOSTbodies beforeGraphQLHandleruses them, andGraphQLWSHandlerhas to be installed before the HTTP handler on the same path.Schema-first means more explicit input normalization. You own the contract, so you own the parsing too.
GraphiQL is convenient in dev and test. Think before exposing it in production, even though the extension makes that easy.
Closing the loop
We built a GraphQL service on Quarkus where the SDL stayed in charge. GraphQL Java handled execution. Vert.x handled the HTTP and WebSocket routing. Quarkus did the platform work around CDI, packaging, test bootstrapping, and the GraphiQL UI.
I think about quarkus-vertx-graphql as the thin path for cases where code-first generation does too much. You give up convenience, and you get control back.



