From Magento to Shopify Plus with no downtime: read the story of Tannico's replatforming project →
25 Feb 2026 System Integration, Shopify, Software Development9 mins

ActiveShopifyGraphQL: The Library That Puts The Joy Back into Shopify's GraphQL API

Nicolò Rebughini

Nicolò Rebughini

If you've built a Shopify app with a Rails backend, you know the routine. You write a verbose GraphQL query string, fire it through the client, then start drilling into a nested hash response: response["data"]["customer"]["defaultEmailAddress"]["emailAddress"]. Repeat that across dozens of queries, each returning a slightly different shape, and your codebase turns into an archaeology dig site where nobody remembers which hash key lives where.

Shopify has been pushing hard toward GraphQL as the primary API surface, deprecating REST endpoints and funneling developers toward a more powerful but significantly more verbose interface. The problem is that Shopify's official Ruby tooling stops at giving you a client and an OpenStruct-style response. There's no domain modeling layer, no way to say "a Product always looks like this" across your entire codebase.

We built ActiveShopifyGraphQL to fix that.

The Hidden Cost of Raw GraphQL Queries

The pain starts small. You write one query to fetch a product's title and price. Then another to fetch variants. Then someone needs metafields. Before long, you have query classes, query superclasses, helper methods for parsing responses, and OpenStruct wrappers that give you dot notation but still require you to know the exact shape of every response.

Here's what a typical Shopify GraphQL interaction looks like in Ruby without any abstraction:

query = <<~GRAPHQL
  query($id: ID!) {
    customer(id: $id) {
      id
      displayName
      defaultEmailAddress {
        emailAddress
      }
      orders(first: 10) {
        edges {
          node {
            id
            name
          }
        }
      }
    }
  }
GRAPHQL

response = client.query(query: query, variables: { id: "gid://shopify/Customer/123" })
customer = response.body["data"]["customer"]
email = customer["defaultEmailAddress"]["emailAddress"]
created_at = Time.parse(customer["createdAt"])
orders = customer["orders"]["edges"].map { |e| e["node"] }

This works, sure. But now multiply it by every entity in your app, and you start having problems:

  • Inconsistent object shapes. Because GraphQL encourages fetching only what you need, the same conceptual entity (a product, a variant) ends up with different attributes in different parts of your codebase. In one place a variant has a SKU; in another, only a price. Your brain has to work overtime to track which version you're dealing with.
  • Multiple API schemas for the same data. If you're building a customer account portal, you might use Shopify's Customer Account API alongside the Admin API. Both can return a customer, but the email address lives at defaultEmailAddress.emailAddress in the Admin API and emailAddress.emailAddress in the Customer Account API. Same concept, different paths, different code.
  • Maintenance burden at scale. When Shopify updates their API version, you need to hunt down every query string scattered across your codebase, update field names, fix response parsing, and update all the tests. That time goes into maintaining plumbing instead of shipping features.

At Nebulab, we deal with the GraphQL API every day, so we set out to solve the problem once and for all.

What ActiveShopifyGraphQL Does

The gem lets you define Shopify entities as Ruby model classes with typed attributes, path mappings, and associations. It then auto-generates GraphQL fragments from those definitions, executes queries, and maps responses back to your model instances. No hand-written query strings for standard reads.

Here's the same customer interaction from above:

class Customer < ActiveShopifyGraphQL::Model
  graphql_type "Customer"

  attribute :id, type: :string
  attribute :name, path: "displayName", type: :string
  attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
  attribute :created_at, type: :datetime

  has_many_connected :orders, default_arguments: { first: 10 }
end

customer = Customer.includes(:orders).find(123456789)
customer.name       # => "John Doe"
customer.email      # => "john@example.com"
customer.created_at # => 2024-03-15 14:30:00 UTC
customer.orders     # Already loaded, no additional query

A few things are happening automatically here:

  • The path option tells the gem where to find a value in the GraphQL response, so defaultEmailAddress.emailAddress gets mapped to a simple email attribute on your model.
  • Type coercion handles the conversion (strings stay strings, createdAt becomes a Ruby Time object).
  • camelCase to snake_case conversion is automatic: you define created_at, and the gem knows to request createdAt from the API.

The graphql_type declaration is optional when your class name matches the Shopify type. A class called Product will automatically map to the Product GraphQL type, but you can override it when Shopify's naming diverges from yours, which happens more often than you'd expect!

The Query API You Know and Love

If you've used ActiveRecord, the query interface will feel immediately familiar:

# Find by GID or numeric ID
customer = Customer.find("gid://shopify/Customer/123456789")
customer = Customer.find(123456789)

# Filter with hash syntax
Customer.where(email: "john@example.com")

# Or Shopify's search query syntax for wildcards
Customer.where("email:*@example.com")

# Chain methods
Customer.where(email: "@example.com")
        .order(sort_key: "CREATED_AT", reverse: true)
        .limit(25)

# Select specific fields to reduce API cost
Customer.select(:id, :name).find(123)

The where method accepts either a hash (which gets auto-escaped and formatted into Shopify's search syntax) or a raw query string for more complex filtering. Error handling works too: if you query on an unsupported attribute, the gem surfaces Shopify's validation error rather than silently failing.

Pagination is cursor-based under the hood, as Shopify's GraphQL API requires. The gem handles this transparently:

# Automatic pagination up to a limit
ProductVariant.where("-sku:''").limit(100).to_a

# Batch processing with explicit page control
ProductVariant.where("sku:FRZ*").in_pages(of: 50) do |page|
  page.each { |variant| process(variant) }
end

# Lazy enumeration
Customer.where(email: "@example.com").each { |c| puts c.name }

Using Connections and Eager Loading

GraphQL connections (the edges { node { ... } } pattern) are one of the most tedious parts of working with Shopify's API. ActiveShopifyGraphQL models them as associations:

class Product < ActiveShopifyGraphQL::Model
  attribute :id, type: :string
  attribute :title, type: :string

  has_many_connected :variants,
    class_name: "ProductVariant",
    default_arguments: { first: 10 },
    inverse_of: :product
end

class ProductVariant < ActiveShopifyGraphQL::Model
  attribute :id, type: :string
  attribute :sku, type: :string
  attribute :price, type: :string

  has_one_connected :product, inverse_of: :variants
end

By default, connections are lazy loaded. Calling product.variants triggers a separate GraphQL query only when you actually access the variants. But when you know you'll need them, includes lets you eager load everything in a single query:

# One query fetches the product AND its variants
product = Product.includes(:variants).find(123)
product.variants.each { |v| puts v.sku }  # No additional queries

The inverse_of option enables bidirectional caching. When you eager load a product with its variants, each variant already knows its parent product without making another round trip:

product = Product.includes(:variants).find(123)
product.variants.first.product  # Returns cached parent, no query

This matters because Shopify's GraphQL API has rate limits measured in query cost points: every unnecessary round trip burns through your budget. Eager loading and inverse caching let you get more data in fewer, cheaper queries.

Bridging the GraphQL API with Your ActiveRecord Models

Most Shopify apps store some data locally in a relational database while pulling other data from Shopify's API.

ActiveShopifyGraphQL includes a GraphQLAssociations module that bridges the two worlds:

class Rating < ApplicationRecord
  include ActiveShopifyGraphQL::GraphQLAssociations

  belongs_to_graphql :product
  # Expects a `shopify_product_id` column in your ratings table
end

rating = Rating.find(1)
rating.product  # Loads Product from Shopify via GraphQL using shopify_product_id

The convention follows a shopify_{association_name}_id pattern for foreign keys, but you can override it with foreign_key:.

Oh, and going the other direction works too: a GraphQL model can declare has_many :ratings to query your local database for associated records.

Switching Between Admin and Customer Account APIs

One of the trickiest aspects of Shopify development is dealing with the Admin API and Customer Account API simultaneously. They return the same conceptual entities but with different response structures.

ActiveShopifyGraphQL handles this with the for_loader block:

class Customer < ActiveShopifyGraphQL::Model
  graphql_type "Customer"

  attribute :id, type: :string
  attribute :name, path: "displayName", type: :string

  # Admin API: email lives here
  for_loader ActiveShopifyGraphQL::Loaders::AdminApiLoader do
    attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
  end

  # Customer Account API: email lives here instead
  for_loader ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader do
    attribute :email, path: "emailAddress.emailAddress", type: :string
  end
end

# Same model, different data sources
admin_customer = Customer.with_admin_api.find(123)
account_customer = Customer.with_customer_account_api(token).find(123)

admin_customer.email    # Works
account_customer.email  # Also works, different underlying path

Your application code doesn't need to care which API the data came from. A Customer is a Customer. The cognitive load drops significantly when your team can reason about stable domain objects instead of tracking API-specific response shapes.

Loading Metafields and Custom Data

Shopify merchants rely heavily on metafields for custom product data. The gem provides a dedicated metafield_attribute method that handles the query generation and response parsing:

class Product < ActiveShopifyGraphQL::Model
  attribute :id, type: :string
  attribute :title, type: :string

  metafield_attribute :boxes_available,
    namespace: "custom",
    key: "available_boxes",
    type: :integer

  metafield_attribute :seo_description,
    namespace: "seo",
    key: "meta_description",
    type: :string

  metafield_attribute :product_data,
    namespace: "custom",
    key: "data",
    type: :json
end

product = Product.find(123)
product.boxes_available  # => 24
product.product_data     # => { "weight" => "2.5kg", "origin" => "Italy" }

For more complex scenarios like metaobjects (Shopify's custom data models), the gem also supports raw GraphQL injection with custom transforms. This is the escape hatch for when the standard attribute mapping can't express what you need.

Setting Up a Shared Base Class

ActiveShopifyGraphQL allows you to configure a base class that your models will inherit from.

At Nebulab, we use this to standardize ID handling across all our models. Shopify uses GIDs (Global IDs like gid://shopify/Product/123), but you often need the numeric part for database lookups or URL construction:

class ApplicationShopifyRecord < ActiveShopifyGraphQL::Model
  attribute :id, transform: ->(gid) { gid.split("/").last }
  attribute :gid, path: "id"
end

class Product < ApplicationShopifyRecord
  attribute :title, type: :string
  attribute :vendor, type: :string
end

product = Product.find(123)
product.id   # => "123" (numeric part only)
product.gid  # => "gid://shopify/Product/123" (full GID)

This eliminates the constant GID-to-numeric-ID conversion that plagues Shopify codebases, especially when you need to compare IDs coming from Liquid themes (numeric) with IDs from GraphQL (GIDs).

Of course, you can also use the shared base class for custom helpers and any other logic you don't want to duplicate.

Where the Gem Is Headed

Right now, ActiveShopifyGraphQL is read-only. Mutation support is on the roadmap but presents some challenges due to how inconsistent Shopify mutations are.

The roadmap also includes metaobject modeling as first-class models, a caching layer, advanced error handling with retry mechanisms, and rate limit management.

We'll keep building these features as the need arises in our client projects, so stay tuned for updates!

Ready to Put the Joy Back Into graphQL?

Raw GraphQL in a growing Shopify app creates a maintenance burden that compounds over time: inconsistent object shapes, duplicated query logic, manual type coercion, and the constant mental overhead of tracking which hash key lives where.

ActiveShopifyGraphQL replaces that with stable domain models, typed attributes, and an interface that Rails developers already know.

The library is production-tested, available as a Ruby gem, and ready to drop into your existing Shopify app. Give it a try, open an issue if something breaks, and let us know what you'd build on top of it!

And if you're building a Shopify app or custom storefront and drowning in GraphQL boilerplate, we've been there. Let's talk!

You may also like

Are you ready to explore the
new commerce frontier?