Back to Spring Boot Course

Spring Boot Course

REST APIs are hard to design so they serve multiple clients well. As each client has their own needs in terms of data searching, filtering and which fields they want, a traditional REST API will provide a single version of an entity and the client has the responsibility of navigating through multiple endpoints and correlating the data on their side to build the data they want.

GraphQL was developed by Facebook to overcome the shortcomings they found with REST APIs. It's a query language for APIs and a runtime for fulfilling those queries with your existing data. By providing a complete and understandable description of the data in your API, clients can leverage that and have the power to ask for exactly what they need. By exposing the schema, it makes it easier to evolve APIs over time. Clients can still navigate the schema the way they need even if you add more fields and relations to it.

To make this concrete, let's build something closer to a real-world system: a vehicle insurance API. An insurer needs to track owners, the vehicles they hold policies on, and the insurance policies themselves (coverage type, premium, dates). This is a much better fit for GraphQL than a simple single-entity example, because:

  • A mobile app might only want an owner's name and their vehicles' license plates.
  • A claims-processing back office might want the full policy history, premiums, and coverage details for a single vehicle.
  • A pricing engine might want to query many vehicles at once, filtered by make/model/year, without extra round trips.

With REST you'd likely need several endpoints (/owners/{id}, /owners/{id}/vehicles, /vehicles/{id}/policies, ...) and the client would stitch the results together. With GraphQL, each of these clients sends one query describing exactly the shape of data it needs.

We'll build two Spring Boot applications:

  1. insurance-graphql-api — the GraphQL server, exposing owners, vehicles and policies.
  2. insurance-graphql-client — a Spring Boot GraphQL client that calls the API programmatically (the way a real backend-for-frontend or another microservice would).

Domain model

Owner, Vehicle and InsurancePolicy domain model

One Owner has many Vehicles, and one Vehicle has many InsurancePolicys over time. A single GraphQL query can traverse all three in one round trip — the client picks exactly which fields it needs at each level.


Part 1 — The GraphQL API server

Dependencies (pom.xml)

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.leangen.graphql</groupId> <artifactId>graphql-spqr-spring-boot-starter</artifactId> <version>0.0.6</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

application.yml

server: port: 8080 spring: datasource: url: jdbc:h2:mem:insurancedb driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: true h2: console: enabled: true graphql: spqr: gui: enabled: true

Entities

Owner.java

@Entity @Data @SuperBuilder @AllArgsConstructor @NoArgsConstructor public class Owner { @Id @GeneratedValue @GraphQLQuery(name = "id", description = "Owner's id") private Long id; @GraphQLQuery(name = "name", description = "Owner's full name") private String name; @GraphQLQuery(name = "email", description = "Owner's email") private String email; @GraphQLQuery(name = "phone", description = "Owner's phone number") private String phone; @OneToMany(mappedBy = "owner", cascade = CascadeType.ALL) @GraphQLQuery(name = "vehicles", description = "Vehicles registered to this owner") @Builder.Default private List<Vehicle> vehicles = new ArrayList<>(); }

Vehicle.java

@Entity @Data @SuperBuilder @AllArgsConstructor @NoArgsConstructor public class Vehicle { @Id @GeneratedValue @GraphQLQuery(name = "id", description = "Vehicle's id") private Long id; @GraphQLQuery(name = "vin", description = "Vehicle Identification Number") private String vin; @GraphQLQuery(name = "make", description = "Manufacturer, e.g. Toyota") private String make; @GraphQLQuery(name = "model", description = "Model, e.g. Corolla") private String model; @GraphQLQuery(name = "year", description = "Model year") private int year; @GraphQLQuery(name = "licensePlate", description = "License plate number") private String licensePlate; @ManyToOne @JoinColumn(name = "owner_id") @GraphQLQuery(name = "owner", description = "This vehicle's registered owner") private Owner owner; @OneToMany(mappedBy = "vehicle", cascade = CascadeType.ALL) @GraphQLQuery(name = "policies", description = "Insurance policies covering this vehicle") @Builder.Default private List<InsurancePolicy> policies = new ArrayList<>(); }

InsurancePolicy.java

@Entity @Data @SuperBuilder @AllArgsConstructor @NoArgsConstructor public class InsurancePolicy { @Id @GeneratedValue @GraphQLQuery(name = "id", description = "Policy id") private Long id; @GraphQLQuery(name = "policyNumber", description = "Human readable policy number") private String policyNumber; @GraphQLQuery(name = "coverageType", description = "e.g. LIABILITY, COMPREHENSIVE, COLLISION") private String coverageType; @GraphQLQuery(name = "premiumAmount", description = "Monthly premium") private BigDecimal premiumAmount; @GraphQLQuery(name = "startDate", description = "Coverage start date") private LocalDate startDate; @GraphQLQuery(name = "endDate", description = "Coverage end date") private LocalDate endDate; @ManyToOne @JoinColumn(name = "vehicle_id") @GraphQLQuery(name = "vehicle", description = "Vehicle covered by this policy") private Vehicle vehicle; }

Repositories

@Repository public interface OwnerRepository extends JpaRepository<Owner, Long> { } @Repository public interface VehicleRepository extends JpaRepository<Vehicle, Long> { Optional<Vehicle> findByVin(String vin); List<Vehicle> findByOwnerId(Long ownerId); } @Repository public interface InsurancePolicyRepository extends JpaRepository<InsurancePolicy, Long> { List<InsurancePolicy> findByVehicleId(Long vehicleId); }

Service layer — queries and mutations

This is where the schema is actually defined, the same way as the original FoodService, but now covering three related entities plus a couple of calculated fields that are only meaningful in an insurance context (isActive on a policy, isHighRisk on a vehicle).

@Service @GraphQLApi public class InsuranceService { private final OwnerRepository ownerRepository; private final VehicleRepository vehicleRepository; private final InsurancePolicyRepository policyRepository; public InsuranceService(OwnerRepository ownerRepository, VehicleRepository vehicleRepository, InsurancePolicyRepository policyRepository) { this.ownerRepository = ownerRepository; this.vehicleRepository = vehicleRepository; this.policyRepository = policyRepository; } // ---------- Owners ---------- @GraphQLQuery(name = "owners") public List<Owner> getOwners() { return ownerRepository.findAll(); } @GraphQLMutation(name = "saveOwner") public Owner saveOwner(@GraphQLArgument(name = "owner") Owner owner) { return ownerRepository.save(owner); } // ---------- Vehicles ---------- @GraphQLQuery(name = "vehicles") public List<Vehicle> getVehicles() { return vehicleRepository.findAll(); } @GraphQLQuery(name = "vehicleByVin") public Optional<Vehicle> getVehicleByVin(@GraphQLArgument(name = "vin") String vin) { return vehicleRepository.findByVin(vin); } @GraphQLQuery(name = "vehiclesByOwner") public List<Vehicle> getVehiclesByOwner(@GraphQLArgument(name = "ownerId") Long ownerId) { return vehicleRepository.findByOwnerId(ownerId); } @GraphQLMutation(name = "saveVehicle") public Vehicle saveVehicle(@GraphQLArgument(name = "vehicle") Vehicle vehicle, @GraphQLArgument(name = "ownerId") Long ownerId) { Owner owner = ownerRepository.findById(ownerId) .orElseThrow(() -> new IllegalArgumentException("Owner not found: " + ownerId)); vehicle.setOwner(owner); return vehicleRepository.save(vehicle); } @GraphQLQuery(name = "isHighRisk") // Calculated property of Vehicle public boolean isHighRisk(@GraphQLContext Vehicle vehicle) { int currentYear = LocalDate.now().getYear(); return (currentYear - vehicle.getYear()) > 15; } // ---------- Policies ---------- @GraphQLQuery(name = "policiesByVehicle") public List<InsurancePolicy> getPoliciesByVehicle(@GraphQLArgument(name = "vehicleId") Long vehicleId) { return policyRepository.findByVehicleId(vehicleId); } @GraphQLMutation(name = "createPolicy") public InsurancePolicy createPolicy(@GraphQLArgument(name = "vehicleId") Long vehicleId, @GraphQLArgument(name = "policy") InsurancePolicy policy) { Vehicle vehicle = vehicleRepository.findById(vehicleId) .orElseThrow(() -> new IllegalArgumentException("Vehicle not found: " + vehicleId)); policy.setVehicle(vehicle); return policyRepository.save(policy); } @GraphQLMutation(name = "cancelPolicy") public void cancelPolicy(@GraphQLArgument(name = "id") Long id) { policyRepository.deleteById(id); } @GraphQLQuery(name = "isActive") // Calculated property of InsurancePolicy public boolean isActive(@GraphQLContext InsurancePolicy policy) { LocalDate today = LocalDate.now(); return !today.isBefore(policy.getStartDate()) && !today.isAfter(policy.getEndDate()); } }

Run it

cd insurance-graphql-api mvn spring-boot:run

Open http://localhost:8080/gui to use the built-in GraphQL playground, or POST directly to http://localhost:8080/graphql.

Sample requests

Create an owner:

mutation { saveOwner(owner: { name: "Priya Raman", email: "priya@example.com", phone: "555-0101" }) { id name } }

Register a vehicle for that owner (assume the owner's id came back as 1):

mutation { saveVehicle( ownerId: 1 vehicle: { vin: "1HGCM82633A123456", make: "Honda", model: "Accord", year: 2015, licensePlate: "TN09AB1234" } ) { id make model isHighRisk } }

Create a policy on that vehicle (assume the vehicle's id came back as 1):

mutation { createPolicy( vehicleId: 1 policy: { policyNumber: "POL-2026-0001" coverageType: "COMPREHENSIVE" premiumAmount: 89.50 startDate: "2026-01-01" endDate: "2026-12-31" } ) { id policyNumber isActive } }

Fetch everything about a vehicle in one round trip — owner, vehicle details, and every policy with its status:

{ vehicleByVin(vin: "1HGCM82633A123456") { make model year isHighRisk owner { name email } policies { policyNumber coverageType premiumAmount isActive } } }
{ "data": { "vehicleByVin": { "make": "Honda", "model": "Accord", "year": 2015, "isHighRisk": true, "owner": { "name": "Priya Raman", "email": "priya@example.com" }, "policies": [ { "policyNumber": "POL-2026-0001", "coverageType": "COMPREHENSIVE", "premiumAmount": 89.50, "isActive": true } ] } } }

Notice this is exactly the kind of query a REST API would normally need three endpoints for (/vehicles/{id}, /owners/{id}, /vehicles/{id}/policies) — here it's one request, shaped by the client.


Part 2 — A GraphQL client (Spring Boot)

A real system rarely stops at the GraphQL playground — some other service or backend-for-frontend needs to call this API programmatically. Spring for GraphQL ships HttpGraphQlClient, a typed client built on WebClient, which is the cleanest way to do this from another Spring Boot app.

Dependencies (pom.xml)

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-graphql</artifactId> </dependency> </dependencies>

Client configuration

@Configuration public class GraphQlClientConfig { @Bean public HttpGraphQlClient graphQlClient(WebClient.Builder webClientBuilder) { WebClient webClient = webClientBuilder .baseUrl("http://localhost:8080/graphql") .build(); return HttpGraphQlClient.builder(webClient).build(); } }

DTOs the client deserializes into

These mirror the fields the client actually asks for — not the full server-side entities. That's the point of GraphQL: the client's shape doesn't need to match the server's schema one-to-one.

public record OwnerView(String name, String email) {} public record PolicyView(String policyNumber, String coverageType, BigDecimal premiumAmount, boolean isActive) {} public record VehicleView(String make, String model, int year, boolean isHighRisk, OwnerView owner, List<PolicyView> policies) {}

Calling the API

@Service public class InsuranceClientService { private final HttpGraphQlClient graphQlClient; public InsuranceClientService(HttpGraphQlClient graphQlClient) { this.graphQlClient = graphQlClient; } public VehicleView getVehicleByVin(String vin) { String query = """ query VehicleByVin($vin: String!) { vehicleByVin(vin: $vin) { make model year isHighRisk owner { name email } policies { policyNumber coverageType premiumAmount isActive } } } """; return graphQlClient.document(query) .variable("vin", vin) .retrieve("vehicleByVin") .toEntity(VehicleView.class) .block(); } public String createPolicy(Long vehicleId, String policyNumber, String coverageType, BigDecimal premium, LocalDate start, LocalDate end) { String mutation = """ mutation CreatePolicy($vehicleId: Long!, $policy: InsurancePolicyInput!) { createPolicy(vehicleId: $vehicleId, policy: $policy) { policyNumber } } """; Map<String, Object> policyInput = Map.of( "policyNumber", policyNumber, "coverageType", coverageType, "premiumAmount", premium, "startDate", start.toString(), "endDate", end.toString() ); return graphQlClient.document(mutation) .variable("vehicleId", vehicleId) .variable("policy", policyInput) .retrieve("createPolicy.policyNumber") .toEntity(String.class) .block(); } }

Exposing it as a small REST facade (optional)

If another team only speaks REST, you can wrap the GraphQL client behind a normal controller — this is a common "backend-for-frontend" pattern:

@RestController @RequestMapping("/api/vehicles") public class VehicleController { private final InsuranceClientService insuranceClientService; public VehicleController(InsuranceClientService insuranceClientService) { this.insuranceClientService = insuranceClientService; } @GetMapping("/{vin}") public VehicleView getVehicle(@PathVariable String vin) { return insuranceClientService.getVehicleByVin(vin); } }

Run it

cd insurance-graphql-client mvn spring-boot:run

With the server running on port 8080, start the client on a different port (e.g. server.port: 8081 in its application.yml), then:

curl http://localhost:8081/api/vehicles/1HGCM82633A123456

which internally sends the GraphQL query above to the server and returns the typed VehicleView as JSON — proving the round trip end to end: client → GraphQL query → server resolvers → JPA → database → back through GraphQL → typed Java object → REST response.


The request lifecycle

screenshot of the app

The REST caller never talks GraphQL directly — the client app translates the request into a GraphQL document, the server resolves only the requested fields, and the client maps the JSON back into typed Java objects before returning plain REST JSON.

Testing locally, end to end

  1. Start insurance-graphql-api (port 8080). Use the /gui playground to create an owner, a vehicle, and a policy, as shown in Part 1.
  2. Start insurance-graphql-client (port 8081).
  3. Hit curl http://localhost:8081/api/vehicles/<vin> and confirm you get back exactly the fields the client's query asked for — no more, no less.
  4. Try changing the client's query (add or remove a field, e.g. licensePlate) and re-run — no server changes are needed, which is the core benefit GraphQL gives you over REST.

Summary

GraphQL shines once you have more than one entity and more than one kind of consumer — which is why the vehicle/owner/policy model here is a better showcase than a single flat entity. The server (graphql-spqr) exposes a schema driven entirely by annotations on the domain classes and service methods, while the client (spring-graphql's HttpGraphQlClient) lets another Spring Boot application consume that schema with fully typed requests and responses. Together they demonstrate the whole loop: a client asking for exactly the shape of data it needs, and a server fulfilling that request without maintaining a combinatorial explosion of REST endpoints.