GraphQL & gRPC

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against async-graphql 7.2.1, async-graphql-axum 7.2.1, tonic 0.14.6 and prost 0.14 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Bookshelf exposes its catalogue as a REST API; this page adds a GraphQL endpoint for the web front end and a gRPC service for internal consumers, all served by the same process and the same port. GraphQL itself — schema design, resolvers, pagination, security — is covered in the GraphQL Reference; this page covers only the Rust side.

The combined service

[dependencies]
axum = { version = "0.8.9", features = ["http2", "ws"] }
tokio = { version = "1.53", features = ["full"] }
async-graphql = { version = "7.2.1", features = ["dataloader"] }
async-graphql-axum = "7.2.1"
tonic = "0.14.6"
tonic-prost = "0.14.6"
prost = "0.14"
futures = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }

[build-dependencies]
tonic-prost-build = "0.14"

The gRPC contract is a protobuf file:

syntax = "proto3";

package bookshelf.catalog.v1;

service Catalog {
  rpc GetBook (GetBookRequest) returns (Book);
}

message GetBookRequest {
  string isbn = 1;
}

message Book {
  string isbn = 1;
  string title = 2;
  uint32 year = 3;
}

tonic 0.14 split code generation out of tonic itself: messages are generated by prost through tonic-prost-build, and the generated code depends on the small tonic-prost runtime crate. The build script runs protoc, which must be installed (apt install protobuf-compiler), or supplied by the protoc-bin-vendored crate through the PROTOC environment variable:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_prost_build::compile_protos("proto/catalog.proto")?; // generates bookshelf.catalog.v1.rs in OUT_DIR
    Ok(())
}

The application serves GraphQL at /graphql (GraphiQL on GET, queries on POST), GraphQL subscriptions over WebSocket at /graphql/ws, and the bookshelf.catalog.v1.Catalog gRPC service, from one Router:

use std::collections::HashMap;

use async_graphql::{
    Context, EmptyMutation, Object, Schema, SimpleObject, Subscription,
    dataloader::{DataLoader, Loader},
    http::GraphiQLSource,
};
use async_graphql_axum::{GraphQL, GraphQLSubscription};
use axum::{
    Router,
    response::{Html, IntoResponse},
    routing::get,
};
use futures::{Stream, StreamExt};
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;

pub mod pb {
    tonic::include_proto!("bookshelf.catalog.v1");
}

// ---------- GraphQL ----------
#[derive(SimpleObject, Clone)]
#[graphql(complex)]
struct Book {
    isbn: String,
    title: String,
    author_id: u32,
}

#[derive(SimpleObject, Clone)]
struct Author {
    id: u32,
    name: String,
}

#[async_graphql::ComplexObject]
impl Book {
    /// Resolved through a DataLoader: N books -> one batched author lookup.
    async fn author(&self, ctx: &Context<'_>) -> async_graphql::Result<Option<Author>> {
        Ok(ctx.data_unchecked::<DataLoader<AuthorLoader>>().load_one(self.author_id).await?)
    }
}

struct AuthorLoader;

impl Loader<u32> for AuthorLoader {
    type Value = Author;
    type Error = std::sync::Arc<std::io::Error>;

    async fn load(&self, ids: &[u32]) -> Result<HashMap<u32, Author>, Self::Error> {
        println!("loading authors {ids:?} in one query");
        Ok(ids.iter().map(|&id| (id, Author { id, name: format!("Author {id}") })).collect())
    }
}

struct Query;

#[Object]
impl Query {
    async fn books(&self) -> Vec<Book> {
        vec![
            Book { isbn: "9780441013593".into(), title: "Dune".into(), author_id: 1 },
            Book { isbn: "9780441478125".into(), title: "The Left Hand of Darkness".into(), author_id: 2 },
            Book { isbn: "9780593099322".into(), title: "Dune Messiah".into(), author_id: 1 },
        ]
    }
}

struct Subscription;

#[Subscription]
impl Subscription {
    /// New reviews, as they are posted.
    async fn reviews(&self, ctx: &Context<'_>) -> impl Stream<Item = String> + use<> {
        let rx = ctx.data_unchecked::<broadcast::Sender<String>>().subscribe();
        BroadcastStream::new(rx).filter_map(|msg| async move { msg.ok() })
    }
}

type BookshelfSchema = Schema<Query, EmptyMutation, Subscription>;

async fn graphiql() -> impl IntoResponse {
    Html(GraphiQLSource::build().endpoint("/graphql").subscription_endpoint("/graphql/ws").finish())
}

// ---------- gRPC ----------
#[derive(Default)]
struct CatalogService;

#[tonic::async_trait]
impl pb::catalog_server::Catalog for CatalogService {
    async fn get_book(&self, request: tonic::Request<pb::GetBookRequest>) -> Result<tonic::Response<pb::Book>, tonic::Status> {
        let isbn = request.into_inner().isbn;
        if isbn == "9780441013593" {
            Ok(tonic::Response::new(pb::Book { isbn, title: "Dune".into(), year: 1965 }))
        } else {
            Err(tonic::Status::not_found(format!("no book {isbn}")))
        }
    }
}

#[tokio::main]
async fn main() {
    let (reviews_tx, _) = broadcast::channel::<String>(64);
    let schema: BookshelfSchema = Schema::build(Query, EmptyMutation, Subscription)
        .data(DataLoader::new(AuthorLoader, tokio::spawn))
        .data(reviews_tx.clone())
        .finish();

    let graphql = Router::new()
        .route("/graphql", get(graphiql).post_service(GraphQL::new(schema.clone())))
        .route_service("/graphql/ws", GraphQLSubscription::new(schema));

    let grpc = tonic::service::Routes::new(pb::catalog_server::CatalogServer::new(CatalogService)).into_axum_router();

    let app = graphql.merge(grpc);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s -H 'content-type: application/json' -d '{"query":"{ books { title author { name } } }"}' localhost:3000/graphql
{"data":{"books":[{"title":"Dune","author":{"name":"Author 1"}},{"title":"The Left Hand of Darkness","author":{"name":"Author 2"}},{"title":"Dune Messiah","author":{"name":"Author 1"}}]}}
# server log: one batched lookup for three books
loading authors [1, 2] in one query

$ cargo run --bin client   # a tonic client calling Catalog/GetBook on the same port
Book { isbn: "9780441013593", title: "Dune", year: 1965 }
NotFound no book 1

GraphQL with async-graphql

  • Schema: #[Object] on an impl block turns its async methods into fields; #[derive(SimpleObject)] exposes a struct’s fields directly, and #[graphql(complex)] with #[ComplexObject] adds computed fields such as author. Schema::build(Query, Mutation, Subscription) assembles the root types; .data(...) registers values that resolvers read with ctx.data_unchecked::<T>() (or the checked ctx.data::<T>()?).

  • Serving: async_graphql_axum::GraphQL is a tower service for POST requests (and GET with a query string); GraphQLSubscription handles the graphql-transport-ws and legacy graphql-ws WebSocket protocols. Both are mounted with post_service/route_service, so axum middleware (tracing, auth, body limits) applies as usual.

  • N+1: a naive author resolver runs one database query per book. A DataLoader collects the keys requested while a query executes and calls Loader::load once with all of them — the log above shows one batch for three books. The pattern is explained on Performance & N+1.

  • Subscriptions return a Stream; here each subscriber gets its own receiver of a tokio::sync::broadcast channel, the same fan-out used for WebSockets on WebSockets. The use<> bound (precise capturing, Rust 1.82) states that the stream borrows nothing from ctx.

  • Security: limit query depth and complexity (Schema::build(...).limit_depth(10).limit_complexity(500)), disable introspection in production if the schema is private, and authorise in resolvers or with guards; see Security & Demand Control.

gRPC with tonic on the same port

  • tonic generates a catalog_server::Catalog trait (implemented with #[tonic::async_trait], still used by tonic 0.14) and a CatalogServer wrapper that is a tower Service.

  • tonic::service::Routes::new(server).into_axum_router() turns one or more gRPC services into an axum::Router that routes /bookshelf.catalog.v1.Catalog/*, ready to merge with the REST and GraphQL routes. Because everything is a tower service, the same TraceLayer and timeout layers wrap all three APIs (see Tower Service Model).

  • gRPC requires HTTP/2. Enable axum’s http2 feature: axum::serve then accepts HTTP/2 with prior knowledge (h2c, as gRPC clients use over plain TCP) alongside HTTP/1.1 on the same port. Behind a TLS-terminating proxy, the proxy must speak HTTP/2 to the upstream too (see TLS & HTTPS).

  • Errors are tonic::Status values with gRPC codes (not_found, invalid_argument, …​), not HTTP status codes.

  • For browsers, gRPC-Web needs the tonic-web layer.

When to choose which

API style Strengths Costs

REST + JSON

cacheable, simple, every client and tool understands it; OpenAPI tooling

many round trips for nested data; over- or under-fetching

GraphQL

clients ask for exactly the fields they need in one round trip; strong schema; subscriptions

HTTP caching is harder; query cost must be limited; resolvers need batching

gRPC

compact binary encoding, streaming in both directions, generated clients in many languages, deadlines

needs HTTP/2, not browser-native, less human-readable

Bookshelf keeps REST as the public API, offers GraphQL to its own front end, and uses gRPC between internal services. The When to use GraphQL or REST page discusses the trade-off in depth.