Lean GraphQL Resolvers with Rich Models in Ruby: A Clean Architecture Approach

Simplifying GraphQL resolvers by extracting logic, avoiding bloated GraphQL Resolvers.

Ignacio Chiazzo
5 min read2 hours ago

A well-known anti-pattern for Ruby on Rails controllers is putting too much logic in controllers. Lean Controllers, Rich Models is a design principle that says most of the application’s business logic should be placed in the model layer, leaving the controller layer as thin as possible. It makes the application code modular and the business logic code maintainable and reusable.

Bloated controllers present four major challenges for software development:

1. Excessive Responsibilities: Bloated controllers frequently assume multiple roles, violating the Single Responsibility Principle.
2. Limited Reusability: The code tends to be less reusable by other APIs, leading to potential redundancy or repetition.
3. Testing and Debugging Complications: Isolating specific components for testing or debugging purposes becomes increasingly difficult due to the intertwined nature of the code.
4. Maintenance Challenges: The complex logic within bloated controllers can make adding or modifying the existing code arduous, occasionally necessitating considerable effort.

Lean Controllers, Rich Models pattern applies to GraphQL resolvers too. Let’s dive deep into an example.

Bloated GraphQL Mutation Resolvers in Ruby on Rails.

Let’s use the following example. We’ll refactor a GraphQL mutation that creates a new image and attaches it to a product.

The mutation receives the product_id and the image as input and returns the new image created if the mutation succeeds. Otherwise, it returns an errors field.

The mutation might result in errors due to various reasons. Certain validations must be performed before inserting the data into the database, while others necessitate communication with the database. In the example below, we ensure the product’s presence prior to its insertion and simultaneously catch any timeout errors when adding the new record.

class ProductCreateImage < GraphQL::Schema::Mutation
argument(:product_id, ID, loads: Product, required: true)
argument(:image, CreateImageInput, required: true)

field(:image, Image, null: true, description: 'The newly created image.')
field(:errors, [Errors], null: false)

def resolve(product_id:, image:)
unless image[:src_image] || image[:url]
return {
errors: { image: I18n.t('src_image_present'), code: Image::ErrorCodes::SrcImageErrorCode }
}
end

product = Product.find_by(id: product_id)
unless product
return { errors:
{ product_id: I18n.t('product_does_not_exist'), code: Image::ErrorCodes::PRODUCT_DOES_NOT_EXIST } }
end

begin
ImagesLock.acquire(product_id: product.id) do
new_image = Image.create(new_image, product.id, image)
if new_image
image.create_default_record
product.touch
else
errors = new_image.errors
end
end
rescue Image::PerProductLimitEnforcer::ProductLimitExceeded => e
errors = GraphApi::UserError.errors_from_hash(image: e.message)
rescue ProductLock::LockAcquireTimeoutError
errors = {
base: I18n.t('image_cannot_be_modified'), code: Image::ErrorCodes::MEDIA_CANNOT_BE_MODIFIED
}
rescue ImageThrottle::DailyLimitExceeded
errors = {
image: I18n.t('image_throttle_error'), code: Media::ErrorCodes::IMAGE_THROTTLE_EXCEEDED
}
end
end
end

Exercise: Try analyzing and refactoring the example code before continuing.

Let’s Analyze the Code!

The mutation does three things:

1. Validations: It does an Active Record call to check the product’s presence. It validates that either the SRC or the URL is present. It also calls a bunch of rescues in case the insert to the database fails.
2. Save to the database: Many things are done during this step: acquiring a database lock, creating an entry with Active Record, and performing an update via product.touch. All of this is wrapped in a begin/rescue block.
3. Database Error Management: Each rescue returns an error with its GraphQL error code.

Let’s Refactor the Code!

Our first step moves the saving and validation logic to their own methods, making the code more encapsulated by following the Single Responsibility Pattern.

class ProductCreateImage < GraphQL::Schema::Mutation
argument :product_id, ID, loads: Product, required: true
argument :image, CreateImageInput, required: true

field :image, Image, null: true, description: 'The newly created image.'
field :errors, [Errors], null: false

def resolve(product_id:, image:)
product = Product.find_by(id: product_id)

errors = errors_from_input(image, product)
return { errors: } if errors

image_result = create_image(image, product)

if image_result.errors
{ errors: image_result.errors }
else
{ image: image_result.image }
end
end

# New!
def create_image(image_input, product)
begin
ImagesLock.acquire(product_id: product.id) do
new_image = Image.create(new_image, product.id, image_input)
if new_image
image.create_default_record


product.touch
else
errors = new_image.errors
end
end
rescue Image::PerProductLimitEnforcer::ProductLimitExceeded => e
errors = GraphApi::UserError.errors_from_hash(image: e.message)
rescue ProductLock::LockAcquireTimeoutError
errors = {
base: I18n.t('image_cannot_be_modified'), code: Image::ErrorCodes::MEDIA_CANNOT_BE_MODIFIED
}
rescue ImageThrottle::DailyLimitExceeded
errors = {
image: I18n.t('image_throttle_error'), code: Image::ErrorCodes::IMAGE_THROTTLE_EXCEEDED
}
end

{ errors:, image: new_image }
end

# New!
def errors_from_input(image_input, product)
unless image_input[:src_image] || image_input[:url]
return {
errors: {
image: I18n.t('src_image_present'), code: Image::ErrorCodes::SrcImageErrorCode
}
}
end

if product
{}
else
{
errors: { product_id: I18n.t('product_does_not_exist'), code: Image::ErrorCodes::PRODUCT_DOES_NOT_EXIST }
}
end
end
end

Great! The resolve method is much simpler and smaller!

Now… should the database logic live inside the controller? Should the controller know there should be a database `Lock` and all the errors that could be raised? No! Let’s move that logic to a service ProductImageCreateService:

class ProductCreateImage < GraphQL::Schema::Mutation
argument :product_id, ID, loads: Product, required: true
argument :image, CreateImageInput, required: true


field :image, Image, null: true, description: 'The newly created image.'
field :errors, [Errors], null: false

def resolve(product_id:, image:)
# Validate input
product = Product.find_by(id: product_id)
errors = errors_from_input(image, product)
return { errors: } if errors

# Service call to mutate the data
image_result = ProductImageCreateService.call(image, product)

# Handle result of the service and map it to the API output
if image_result.errors
{ errors: image_result.errors }
else
{ image: image_result.image }
end
end


def errors_from_input(image_input, product)
# ...
end
end

Looks much better! The code isn’t coupled anymore, and others can reuse the code. The GraphQL resolver contains less business logic and concerns are clearly separated. All of this in three simple steps:

1. Parse and validate input
2. Call a service to mutate the data
3. Map the result of the service to the API response

Other Considerations

The service shouldn’t know about the API layer because it should live in the business logic layer. For example, we could have more than one API. There could be a REST API that does similar things or other mutations that require attaching an image to a product. Extracting our business logic to a service allows us to reuse the same essential logic.

We can use dependency injection to talk to each other. For example, imagine that REST and GraphQL APIs error messages are handled differently. In that case, we could still use a common service class but use Dependency Injection to pass an error-handling object to the Service. This object would map the errors raised by the service to the ones the API should return. We can see that in the following illustration:

Use dependency injection to handle the errors the service raises.

Bloated GraphQL resolvers are as bad as bloated controllers. Lean GraphQL resolvers and rich models make the code more scalable and separate the concerns. The service pattern and dependency injections are great solutions to achieve that.

Thanks for reading! Let me know what you think at X ignaciochiazzo

--

--

Ignacio Chiazzo
Ignacio Chiazzo

Written by Ignacio Chiazzo

Staff Software Engineer at Shopify. Opinions are my own. ignaciochiazzo.com

No responses yet