In real life, data doesn't always live in a single database or service. Sometimes you need to fire requests to multiple microservices or databases at once, merge the results, and reply to the client. Asynchronous calls in GraphQL are a way to handle these tasks efficiently without blocking the main execution thread.
Let's break this down with a simple example: imagine you're building a flight booking app. The client asks for flight info plus airline reviews and rating. Reviews and rating live in one database, while flight data comes from an external API. Calling all those services sequentially will slow down the request. Asynchronous processing lets you kick off all requests in parallel.
How does GraphQL support asynchronicity?
Spring GraphQL supports using asynchronous calls thanks to CompletableFuture from Java. It's the standard tool for working with Future objects, letting you chain async tasks and handle their results transparently.
Async operations in GraphQL are organized at the level of Data Fetchers (data handlers). Instead of returning the result immediately, a Data Fetcher returns a CompletableFuture that gets fulfilled with data when the async execution completes.
Asynchronous Data Fetchers in Spring GraphQL
Example of a basic asynchronous Data Fetcher
Let's see how to implement a simple asynchronous handler in Spring GraphQL. In your app we'll add a fetcher that gets data from an external API.
@Component
public class FlightDataFetcher implements DataFetcher<CompletableFuture<Flight>> {
private final FlightService flightService;
public FlightDataFetcher(FlightService flightService) {
this.flightService = flightService;
}
@Override
public CompletableFuture<Flight> get(DataFetchingEnvironment environment) {
String flightId = environment.getArgument("id");
// Asynchronous call to the service to fetch flight data
return CompletableFuture.supplyAsync(() -> flightService.getFlightById(flightId));
}
}
Here we use CompletableFuture.supplyAsync to fetch data asynchronously. This is the standard Java method to run a task on a separate thread.
Integrating an asynchronous Data Fetcher into a GraphQL schema
Now let's create a GraphQL schema that invokes the async fetcher:
type Query {
flight(id: ID!): Flight
}
type Flight {
id: ID!
airline: String!
departureTime: String!
arrivalTime: String!
}
And register our fetcher in GraphQLRuntimeWiring:
@Configuration
public class GraphQLConfig {
private final FlightDataFetcher flightDataFetcher;
public GraphQLConfig(FlightDataFetcher flightDataFetcher) {
this.flightDataFetcher = flightDataFetcher;
}
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return wiringBuilder -> wiringBuilder
.type("Query", typeWiring -> typeWiring
.dataFetcher("flight", flightDataFetcher));
}
}
Asynchronous calls in a chain
Asynchronicity gets even more useful when you need to combine multiple requests. For example, fetching flight info and the airline it belongs to.
@Component
public class AirlineDataFetcher implements DataFetcher<CompletableFuture<Airline>> {
private final AirlineService airlineService;
public AirlineDataFetcher(AirlineService airlineService) {
this.airlineService = airlineService;
}
@Override
public CompletableFuture<Airline> get(DataFetchingEnvironment environment) {
String airlineId = environment.getArgument("airlineId");
return CompletableFuture.supplyAsync(() -> airlineService.getAirlineById(airlineId));
}
}
Now let's use async composition to combine data:
@Component
public class FlightDetailsFetcher implements DataFetcher<CompletableFuture<FlightDetails>> {
private final FlightService flightService;
private final AirlineService airlineService;
public FlightDetailsFetcher(FlightService flightService, AirlineService airlineService) {
this.flightService = flightService;
this.airlineService = airlineService;
}
@Override
public CompletableFuture<FlightDetails> get(DataFetchingEnvironment environment) {
String flightId = environment.getArgument("id");
CompletableFuture<Flight> flightFuture = CompletableFuture.supplyAsync(() -> flightService.getFlightById(flightId));
CompletableFuture<Airline> airlineFuture = flightFuture.thenCompose(flight ->
CompletableFuture.supplyAsync(() -> airlineService.getAirlineById(flight.getAirlineId())));
// Combine flight and airline data into a single object
return flightFuture.thenCombine(airlineFuture, (flight, airline) -> {
return new FlightDetails(flight, airline);
});
}
}
In this example we use thenCombine, which merges two CompletableFutures and creates the final FlightDetails object.
Approaches to managing asynchronicity
- Errors and timeouts. In async systems you need to handle errors, especially when calling external APIs. A good approach is to wrap the call in a
try-catchand return predefined fallback values on failure. - Performance optimization. Use thread pools (
ThreadPoolExecutor) to limit the number of concurrently running threads. This protects your system from overload. - Complex dependencies. If one request depends on another, try to minimize sequential calls. For example, merge data at the database level when possible.
Practice: Building complex asynchronous queries
Suppose we have the following entities:
Flight(flight information).Airline(airline information).Reviews(reviews for the airline).
Task: create a GraphQL query that returns the flight, the airline, and the reviews in a single call.
In the GraphQL schema:
type Query {
flightDetails(id: ID!): FlightDetails
}
type FlightDetails {
flight: Flight
airline: Airline
reviews: [Review]
}
Fetcher implementation:
@Component
public class FlightDetailsWithReviewsFetcher implements DataFetcher<CompletableFuture<FlightDetailsWithReviews>> {
private final FlightService flightService;
private final AirlineService airlineService;
private final ReviewService reviewService;
public FlightDetailsWithReviewsFetcher(FlightService flightService, AirlineService airlineService, ReviewService reviewService) {
this.flightService = flightService;
this.airlineService = airlineService;
this.reviewService = reviewService;
}
@Override
public CompletableFuture<FlightDetailsWithReviews> get(DataFetchingEnvironment environment) {
String flightId = environment.getArgument("id");
CompletableFuture<Flight> flightFuture = CompletableFuture.supplyAsync(() -> flightService.getFlightById(flightId));
CompletableFuture<Airline> airlineFuture = flightFuture.thenCompose(flight ->
CompletableFuture.supplyAsync(() -> airlineService.getAirlineById(flight.getAirlineId())));
CompletableFuture<List<Review>> reviewsFuture = airlineFuture.thenCompose(airline ->
CompletableFuture.supplyAsync(() -> reviewService.getReviewsForAirline(airline.getId())));
return flightFuture.thenCombine(airlineFuture, (flight, airline) ->
new FlightDetails(flight, airline))
.thenCombine(reviewsFuture, (details, reviews) ->
new FlightDetailsWithReviews(details.getFlight(), details.getAirline(), reviews));
}
}
Best practices for asynchronous systems
- Logging. Log request durations and any errors that occur.
- Testing. Test async calls with mocks and stubs.
- Monitoring. Use metrics and tracing (for example, Spring Sleuth) to analyze system behavior.
CompletableFuture.runAsync(() -> logger.info("Started asynchronous request"));
These approaches will help make your apps more performant and resilient even under heavy load.
GO TO FULL VERSION