Skip to Content
Course content

172: Building a GraphQL Server with async-graphql

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first dive into async-graphql, it feels like magic. You define a few structs, slap some #[Object] macros on them, and suddenly you have a fully typed schema. It's tempting to treat your GraphQL resolvers like simple API endpoints—just a place to fetch some data and return it. But there's a trap here that I've seen countless engineers fall into: the N+1 query problem. If you aren't careful, a single request from a client can accidentally trigger hundreds of database calls, effectively DDOS-ing your own backend.

The "Just Fetch It" Approach

Imagine we're building a digital library. We have Books and Authors. In a naive implementation, you'd probably write your Book object like this:

#[Object]
impl Book {
    async fn author(&self, ctx: &Context<'_>) -> Result<Author> {
        let db = ctx.data<Database>()?;
        // The naive way: fetch the author for every single book
        let author = db.get_author_by_id(self.author_id).await?;
        Ok(author)
    }
}

On the surface, this looks clean. It's intuitive. If the user asks for the author of a book, you go get the author. But here's where it breaks: what happens when a client queries for the top 50 most popular books, including their authors? GraphQL will call the author resolver 50 times. That's one query for the list of books, plus 50 separate queries for authors. That's 51 round-trips to your database for a single HTTP request. It works fine in development with three rows of data, but it'll crawl once you hit production scale.

Batching with DataLoaders

To fix this, we need to stop thinking about "fetching one" and start thinking about "fetching many." async-graphql provides a DataLoader trait specifically for this. Instead of the resolver immediately hitting the database, it submits a "request" for an ID to a loader. The loader then waits for a tiny tick of the event loop, collects all the IDs requested across all concurrent resolvers, and fetches them in a single SELECT ... WHERE id IN (...) query.

Here is how I'd actually structure this. First, we define the loader:

struct AuthorLoader { db: Database }

#[async_trait::async_trait]
impl Loader<UserId, Author> for AuthorLoader {
    async fn load(&self, keys: &[UserId]) -> Result<HashMap<UserId, Author>> {
        // One query to rule them all
        let authors = self.db.get_authors_by_ids(keys).await?;
        Ok(authors.into_iter().map(|a| (a.id, a)).collect())
    }
}

Now, our Book resolver changes. Instead of talking to the database, it talks to the loader:

#[Object]
impl Book {
    async fn author(&self, ctx: &Context<'_>) -> Result<Author> {
        let loader = ctx.data<DataLoader<AuthorLoader>>()?;
        // This doesn't hit the DB immediately; it schedules a batch load
        let author = loader.load_one(self.author_id).await?;
        author.ok_or_else(|| Error::new("Author not found"))
    }
}

The Cost of Efficiency

You might be wondering if this is overkill. Is the extra boilerplate of a Loader struct worth it? In my experience, yes—absolutely. The trade-off is a slight increase in architectural complexity for a massive gain in stability. You're moving from O(N) database pressure to O(1) per field level.

One thing to watch out for: DataLoaders are cached for the duration of a single request. This is usually what you want, as it prevents fetching the same author twice if two different books were written by the same person. However, if you're doing heavy mutations in the same request and expecting the loader to reflect those changes immediately, you'll need to be mindful of how you're managing that cache. For 95% of read-heavy GraphQL APIs, the DataLoader pattern is the non-negotiable way to build.




📋 Practical Task

Implementing a Batched Review Loader for the Library API

You have a Book object that currently fetches its reviews one by one, causing a massive performance hit when listing multiple books. Your task is to implement a ReviewLoader to batch these requests.

Requirements:

  • Create a ReviewLoader struct that implements async_graphql::DataLoader.
  • The load method should take a slice of BookIds and return a HashMap<BookId, Vec<Review>>.
  • Assume the database has a method db.get_reviews_for_books(ids: &[BookId]) that returns a Vec<Review>. You will need to group these reviews by their book_id inside the loader.
  • Update the Book object's reviews resolver to use the DataLoader instead of calling the database directly.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.