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.emailAddressin the Admin API andemailAddress.emailAddressin 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 # => "[email protected]"
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
pathoption tells the gem where to find a value in the GraphQL response, sodefaultEmailAddress.emailAddressgets mapped to a simpleemailattribute on your model. - Type coercion handles the conversion (strings stay strings,
createdAtbecomes a RubyTimeobject). - camelCase to snake_case conversion is automatic: you define
created_at, and the gem knows to requestcreatedAtfrom 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: "[email protected]")
# 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!



