Skip to Content
Course content

188: Resolvers and Mutations in graphql-ruby

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

When people first dive into graphql-ruby, they usually fall into one specific trap: they treat their resolvers and mutations like Rails controllers. They start cramming validation logic, database transactions, and third-party API calls directly into the resolve method. I've seen this in dozens of code reviews. The thought process is usually, "Well, this is where the request hits the server, so this is where the logic goes."

The "Fat Resolver" Fallacy

Let's look at how this usually manifests. Imagine we're building a bookstore API. You might be tempted to write a mutation to update a book's price like this:


class Mutations::UpdateBookPrice < GraphQL::Schema::RelayClassicMutation
  argument :id, ID, required: true
  argument :price, Float, required: true

  def resolve(id:, price:)
    book = Book.find(id)
    if price < 0
      raise GraphQL::ExecutionError, "Price cannot be negative"
    end
    
    book.update!(price: price)
    # I'm imagining a notification system here too
    NotificationService.notify_price_drop(book) if price < book.price_was
    
    book
  end
end

At first glance, it works. But you've just tied your business logic to your transport layer. If you ever need to update a price via a background job, a CLI task, or a legacy REST endpoint, you're stuck. You'd either have to duplicate this logic or do something awkward like manually invoking a GraphQL mutation from inside a Ruby worker. It's a maintenance nightmare waiting to happen.

Orchestration, Not Implementation

The correct way to think about resolvers and mutations is as orchestrators. Their only job is to take the input arguments, hand them off to a domain object or a service class, and then return the result in a format the GraphQL schema understands. I like to keep my resolve methods to about five lines of code maximum.

Let's refactor that bookstore example. We'll move the logic into a dedicated service object. Notice how the mutation becomes a thin wrapper:


# app/services/books/update_price_service.rb
module Books
  class UpdatePriceService
    def self.call(book_id, new_price)
      book = Book.find(book_id)
      raise ArgumentError, "Price cannot be negative" if new_price < 0
      
      book.transaction do
        book.update!(price: new_price)
        NotificationService.notify_price_drop(book) if new_price < book.price_was
      end
      book
    end
  end
end

# app/graphql/mutations/update_book_price.rb
class Mutations::UpdateBookPrice < GraphQL::Schema::RelayClassicMutation
  argument :id, ID, required: true
  argument :price, Float, required: true

  def resolve(id:, price:)
    Books::UpdatePriceService.call(id, price)
  rescue ArgumentError => e
    GraphQL::ExecutionError.new(e.message)
  end
end

Now, the GraphQL layer doesn't care how the price is updated; it only cares that it is updated and that the result is returned. This makes your code vastly easier to test. You can write a fast unit test for UpdatePriceService without loading the entire GraphQL schema.

Handling Complex Resolvers

The same principle applies to read-only resolvers. While graphql-ruby is smart enough to automatically resolve associations (like book.author), you'll eventually hit a wall where you need custom logic—like filtering a list of books based on a user's subscription tier.

Avoid putting that filtering logic in the Field definition. Instead, use a dedicated resolver class. This keeps your type definitions clean and your query logic encapsulated. If your resolver starts getting complex, move the query logic into a scope on the model or a specialized Query object. Remember: if you can't describe what a resolver does without using the word "and" three times, it's doing too much.




📋 Practical Task

Implement a Book Archival System

You need to implement a feature that allows administrators to "archive" a book. Archiving a book isn't just flipping a boolean; it requires updating the archived_at timestamp and logging the action in an AuditLog table.

Your task:

  • Create a service class Books::ArchiveService that handles the database logic: setting archived_at to the current time and creating an AuditLog entry.
  • Implement a GraphQL mutation Mutations::ArchiveBook that accepts a bookId.
  • Ensure the mutation remains a "thin wrapper" by calling the service class.
  • Handle the case where a book is not found by returning a GraphQL::ExecutionError.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.