Skip to main content
  1. Languages/
  2. Rust Guides/

Building Production-Ready GraphQL APIs with async-graphql and Axum in Rust

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

The landscape of web development in 2025 continues to demand more from our APIs: strict type safety, predictable performance, and the ability for clients to request exactly what they need. While REST remains a staple, GraphQL has solidified its place as the go-to solution for complex, data-driven frontends.

For Rust developers, the choice of tools has matured significantly. Gone are the days of wrestling with experimental crates. Today, the combination of async-graphql and Axum offers a robust, production-grade foundation for building GraphQL services.

In this guide, we are going to build a fully functional GraphQL API from scratch. We will move beyond “Hello World” and tackle real-world concepts like mutation handling, application state management, and schema design.

Why Rust and async-graphql?
#

Before we open the terminal, it’s worth understanding why this stack is gaining so much traction in the industry.

  1. Type Safety Everywhere: Rust’s type system maps beautifully to GraphQL’s schema. If your code compiles, your API contract is likely valid.
  2. Performance: async-graphql is designed from the ground up for Rust’s async ecosystem (Tokio), allowing for highly concurrent data fetching without the overhead of garbage collection.
  3. Code-First Approach: Instead of writing a massive .graphql schema file and trying to sync it with your code, async-graphql generates the schema from your Rust structs and functions.

Architecture Overview
#

Here is how the components we are about to build interact with each other:

graph TD Client["Client<br/>(Web / Mobile)"]:::ext -->|"HTTP POST"| Axum["Axum Web Server"]:::rust Axum -->|"Extract Request"| GQL_Handler["GraphQL Handler"]:::rust GQL_Handler -->|"Execute"| Engine["Async-GraphQL Engine"]:::rust subgraph SchemaExecution ["Schema Execution"] direction TB Engine -->|"Parse"| Query["Query Root"]:::rust Engine -->|"Parse"| Mutation["Mutation Root"]:::rust Query -->|"Read"| DB["(In-Memory Store / DB)"]:::ext Mutation -->|"Write"| DB end DB -->|"Result"| Engine Engine -->|"JSON"| Axum Axum -->|"Response"| Client %% classDef 必须放在最后 classDef rust fill:#dea584,stroke:#333,stroke-width:2px,color:black classDef ext fill:#84a5de,stroke:#333,stroke-width:2px,color:black

Prerequisites
#

To follow along, ensure your environment is ready. We are assuming a standard 2025 Rust development setup:

  • Rust: Version 1.75 or later (stable).
  • Cargo: Standard package manager.
  • IDE: VS Code (with rust-analyzer) or RustRover.
  • Terminal: Any standard shell.

Step 1: Project Setup and Dependencies
#

Let’s start by creating a new binary project.

cargo new rust-graphql-api
cd rust-graphql-api

We need to add the necessary crates. We will use tokio for our async runtime, axum for the HTTP server, and async-graphql for the logic. We also need serde for serialization.

Open your Cargo.toml and add the following:

[package]
name = "rust-graphql-api"
version = "0.1.0"
edition = "2021"

[dependencies]
# Web Server
axum = "0.7"
tower-http = { version = "0.5", features = ["cors"] }

# Async Runtime
tokio = { version = "1", features = ["full"] }

# GraphQL
async-graphql = { version = "7.0", features = ["chrono"] }
async-graphql-axum = "7.0"

# Serialization & Utilities
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }

# Easy error handling just for the main function
anyhow = "1.0"

Note: Version numbers are illustrative of a stable 2025 environment. Always check Crates.io for the absolute latest if you are reading this in the distant future.

Step 2: Defining the Domain Model
#

We will build a simple Book Inventory System. In a code-first approach, your Rust structs are your GraphQL types.

Create a new file src/model.rs.

use async_graphql::SimpleObject;
use serde::{Deserialize, Serialize};

// The derive macro 'SimpleObject' maps this struct directly to a GraphQL Object type.
#[derive(Debug, Clone, Serialize, Deserialize, SimpleObject)]
pub struct Book {
    pub id: String,
    pub title: String,
    pub author: String,
    pub published_year: i32,
    pub is_available: bool,
}

impl Book {
    pub fn new(id: String, title: String, author: String) -> Self {
        Self {
            id,
            title,
            author,
            published_year: 2025,
            is_available: true,
        }
    }
}

The #[derive(SimpleObject)] macro is the magic sauce here. It inspects your struct fields and exposes them as GraphQL fields.

Step 3: Managing State
#

For this tutorial, we’ll use a thread-safe in-memory store. In a production app, this would be a connection pool to PostgreSQL or Redis (e.g., sqlx::PgPool).

Create a file src/db.rs:

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::model::Book;

// We use Arc<Mutex<...>> to share state across threads safely.
// In production, use a RWLock or a real DB connection pool.
pub type Storage = Arc<Mutex<HashMap<String, Book>>>;

pub fn init_db() -> Storage {
    let mut books = HashMap::new();
    
    // Seed some data
    let book1 = Book::new("1".into(), "The Rust Programming Language".into(), "Steve Klabnik".into());
    let book2 = Book::new("2".into(), "Zero to Production".into(), "Luca Palmieri".into());
    
    books.insert(book1.id.clone(), book1);
    books.insert(book2.id.clone(), book2);

    Arc::new(Mutex::new(books))
}

Step 4: The GraphQL Schema (Query & Mutation)
#

This is the core of our application. We need to define a Query Root (for reading data) and a Mutation Root (for modifying data).

Create src/schema.rs:

use async_graphql::{Context, Object, Schema, EmptySubscription};
use crate::model::Book;
use crate::db::Storage;

pub struct QueryRoot;

#[Object]
impl QueryRoot {
    // Resolver for fetching all books
    async fn books(&self, ctx: &Context<'_>) -> Vec<Book> {
        let storage = ctx.data::<Storage>().unwrap();
        let books = storage.lock().unwrap();
        books.values().cloned().collect()
    }

    // Resolver for fetching a specific book by ID
    async fn book(&self, ctx: &Context<'_>, id: String) -> Option<Book> {
        let storage = ctx.data::<Storage>().unwrap();
        let books = storage.lock().unwrap();
        books.get(&id).cloned()
    }
}

pub struct MutationRoot;

#[Object]
impl MutationRoot {
    // Resolver to add a new book
    async fn create_book(&self, ctx: &Context<'_>, title: String, author: String) -> Book {
        let storage = ctx.data::<Storage>().unwrap();
        let mut books = storage.lock().unwrap();
        
        let id = uuid::Uuid::new_v4().to_string(); // Requires uuid crate, or just use random string
        let new_book = Book::new(id.clone(), title, author);
        
        books.insert(id, new_book.clone());
        new_book
    }

    // Resolver to delete a book
    async fn delete_book(&self, ctx: &Context<'_>, id: String) -> bool {
        let storage = ctx.data::<Storage>().unwrap();
        let mut books = storage.lock().unwrap();
        books.remove(&id).is_some()
    }
}

// Combine Query and Mutation into a Schema type
pub type InventorySchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;

pub fn create_schema(db: Storage) -> InventorySchema {
    Schema::build(QueryRoot, MutationRoot, EmptySubscription)
        .data(db) // Inject the database into the context
        .finish()
}

Note: For the UUID generation in create_book, you might need to add uuid = { version = "1.0", features = ["v4"] } to your Cargo.toml, or simply use format!("{}", books.len() + 1) for simplicity.

Key Concept: The Context
#

Notice ctx.data::<Storage>(). This is how async-graphql handles dependency injection. We pass our database connection (or pool) into the schema builder, and it becomes available in every resolver. This is crucial for keeping your resolvers clean and testable.

Step 5: Integrating with Axum
#

Now we need to expose this schema via HTTP. async-graphql-axum provides convenient extractors to make this seamless.

Edit src/main.rs:

mod model;
mod db;
mod schema;

use axum::{
    extract::Extension,
    response::{Html, IntoResponse},
    routing::get,
    Router,
};
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use schema::{create_schema, InventorySchema};
use tokio::net::TcpListener;

// GraphQL Handler
async fn graphql_handler(
    schema: Extension<InventorySchema>,
    req: GraphQLRequest,
) -> GraphQLResponse {
    schema.execute(req.into_inner()).await.into()
}

// Playground Handler (The UI to test your API)
async fn graphql_playground() -> impl IntoResponse {
    Html(playground_source(GraphQLPlaygroundConfig::new("/")))
}

#[tokio::main]
async fn main() {
    // 1. Initialize Database
    let db = db::init_db();

    // 2. Create Schema
    let schema = create_schema(db);

    // 3. Build Axum Router
    let app = Router::new()
        .route("/", get(graphql_playground).post(graphql_handler))
        .layer(Extension(schema)); // Add schema as a layer extension

    println!("🚀 Server started at http://localhost:8000");

    // 4. Run Server
    let listener = TcpListener::bind("0.0.0.0:8000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Running the API
#

Everything is set. Open your terminal:

cargo run

Navigate to http://localhost:8000 in your browser. You should see the GraphQL Playground.

Try running a query:

query {
  books {
    id
    title
    author
  }
}

Or a mutation:

mutation {
  createBook(title: "Rust for Web", author: "DevPro Team") {
    id
    title
  }
}

Comparisons: Choosing the Right Tool
#

You might be wondering how async-graphql compares to other ecosystems or approaches.

Feature async-graphql (Rust) Juniper (Rust) Node.js (Apollo) REST (Axum/Actix)
Type Safety High (Compile time) High Medium (Runtime checks) High
Performance Excellent (Native Async) Good Moderate Excellent
Developer Exp Modern (Code-first) Legacy feel Excellent Familiar
Boilerplate Low/Medium High Low Medium
Ecosystem Growing fast Stable/Stagnant Massive Massive

Best Practices and Common Pitfalls
#

Building the API is step one. Making it production-ready is step two. Here are the critical considerations for 2025.

1. The N+1 Problem
#

This is the most common performance killer in GraphQL.

  • The Issue: If you query a list of Authors, and for each author you request their Books, a naive implementation will query the database once for authors, and then once per author for books.
  • The Fix: Use DataLoaders. async-graphql has built-in support for the DataLoader pattern. It batches requests into a single database query.
  • Tip: Never ship a nested relationship resolver that hits the DB directly without a DataLoader.

2. Error Handling
#

Don’t panic! Literally. If a resolver panics, the worker thread might crash (though Axum usually catches it). Return Result<T, async_graphql::Error>. You can extend the error system to return structured error codes (e.g., NOT_FOUND, UNAUTHORIZED) which front-end clients can handle programmatically.

3. Complexity Limits
#

GraphQL puts power in the hands of the client. A malicious client could request a query 10,000 layers deep, crashing your server.

  • Solution: Configure query complexity limits and depth limits in the Schema::build options.
Schema::build(QueryRoot, MutationRoot, EmptySubscription)
    .limit_depth(5) // Max nesting depth
    .limit_complexity(100) // Max calculated complexity
    .data(db)
    .finish()

Conclusion
#

We have successfully built a high-performance, type-safe GraphQL API using Rust. The combination of async-graphql and Axum provides a developer experience that rivals dynamic languages while retaining Rust’s legendary reliability.

Key Takeaways:

  • Use #[derive(SimpleObject)] to map structs to GraphQL.
  • Inject state (DB pools) via the Context.
  • Always be mindful of the N+1 problem in nested resolvers.
  • Secure your API with depth and complexity limits.

The ecosystem is vibrant. As you move forward, look into integrating SQLx for real database interaction and Tracing for observability.

Happy Coding!


Found this article helpful? Subscribe to Rust DevPro for more deep dives into advanced Rust backend engineering.