As a developer, one of the most important aspects of writing code is making it easy to understand, maintain, and scale. This is where the concept of "clean code" comes in. Writing clean code is not just about writing code that works but also code that is readable, easy to modify, and follows best practices.
In this blog post, we will explore one clean code tip that can help you write more maintainable and scalable GraphQL APIs in Ruby on Rails: creating a service object and calling it in the mutation.
What is a service object?
A service object is a design pattern that separates the business logic of an application from the controller or model. It encapsulates a specific business task, such as sending an email or updating a user's information, into a reusable and modular object. Service objects promote code reuse, reduce complexity, and make testing and maintaining the code easier.
In the context of a GraphQL API in Ruby on Rails, a service object can encapsulate a mutation’s business logic. This can help make the mutation code more modular and reusable and make it easier to add or modify functionality in the future.
Creating a service object for a GraphQL mutation in Rails
Let's say we have a GraphQL mutation that signs up the user. The mutation might look something like this:
# app/graphql/mutations/sign_up.rb
module Mutations
# @note: This is the mutation that will be used to sign up a new user.
class SignUp < Mutations::BaseMutation
description 'Sign up a new user'
argument :email, String, required: true, description: 'The email of the user'
argument :name, String, required: true, description: 'The name of the user'
argument :password, String, required: true, description: 'The password of the user'
argument :username, String, required: true, description: 'The username of the user'
field :auth_token, String, null: true, description: 'The auth token of the user'
field :errors, [String], null: true, description: 'The errors of the mutation'
field :user, Types::Objects::UserType, null: true, description: 'The user'
def resolve(username:, password:, email:, name:)
user = User.new(username:, email:, password:, name:)
return { auth_token: nil, errors: user.errors.full_messages, user: nil } unless user.valid?
ActiveRecord::Base.transaction do
user.save!
session = user.sessions.create!
end
auth_token = "Bearer #{JsonWebToken.encode({ token: session.token })}"
{ auth_token:, user:, errors: nil }
rescue StandardError => e
{ auth_token: nil, user: nil, errors: [e.message]
end
end
end
Now we will add send confirmation email, may be we add some slack notifer or other code that we need to execute after user is created through sign up. So soon, this resolve code gets bloated, and many business actions will make our code dirty. Here comes service objects.
While this mutation code works, it can be improved using a service object. Here's how we can create a service object to handle the business logic of updating a user's email:
# app/services/users/signup_service.rb
module Users
# @note: This is the service that will be used to create a new user and a new session.
# @param [String] username
# @param [String] email
# @param [String] password
# @param [String] name
# @return [Struct] result with the user, auth token and error
# @example
# Users::SignupService.call(username: 'username', email: 'email', password: 'password', name: 'name')
# @example result
# result.user # => User
# result.auth_token # => String
# result.errors # => Array
# @step: 1 - Validate the user.
# @step: 2 - Create the user and the session.
# @step: 3 - Encode the session token.
# @step: 4 - Return the result.
class SignupService
attr_accessor :username, :email, :password, :name, :result
# @note: This is the constructor of the service.
def initialize(username:, email:, password:, name:)
@username = username
@email = email
@password = password
@name = name
# @note: This is the struct that will be used to return the result of the service.
@result = Struct.new(:user, :auth_token, :errors)
end
# @note: This is the method that will be called to execute the service.
def call # rubocop:disable Metrics/AbcSize
session = nil
user = User.new(username:, email:, password:, name:)
# @note: This is the validation of the user.
return result.new(nil, nil, user.errors.full_messages) unless user.valid?
# @note: This is the transaction that will be used to create the user and the session.
ActiveRecord::Base.transaction do
user.save!
session = user.sessions.create!
end
# Add more business actions
auth_token = "Bearer #{JsonWebToken.encode({ token: session.token })}"
result.new(user, auth_token, nil)
rescue StandardError => e
result.new(nil, nil, [e.message])
end
class << self
# @note: This is the method that will be called to execute the service.
def call(username:, email:, password:, name:)
new(username:, email:, password:, name:).call
end
end
end
end
Now we can call this service object in our mutation code:
module Mutations
# @note: This is the mutation that will be used to sign up a new user.
class SignUp < Mutations::BaseMutation
description 'Sign up a new user'
argument :email, String, required: true, description: 'The email of the user'
argument :name, String, required: true, description: 'The name of the user'
argument :password, String, required: true, description: 'The password of the user'
argument :username, String, required: true, description: 'The username of the user'
field :auth_token, String, null: true, description: 'The auth token of the user'
field :errors, [String], null: true, description: 'The errors of the mutation'
field :user, Types::Objects::UserType, null: true, description: 'The user'
def resolve(username:, password:, email:, name:)
Users::SignupService.call(username:, password:, email:, name:)
end
end
end
By using a service object, we have encapsulated the business logic of updating a user's email into a modular and reusable object. If we need to add or modify this functionality in the future, we can do so easily by modifying the service object.
Benefits of using a service object in GraphQL mutations
Using a service object in GraphQL mutations has several benefits:
- Encapsulates business logic: The service object encapsulates the business logic of the mutation, making it easier to read, test, and modify.
- Promotes code reuse: By separating the business logic from the mutation code, we can reuse the service object in other application parts.
- Makes code more modular: The service object makes the mutation code more modular and easier to maintain.
- Easier to test: The service object can be tested independently of the mutation code, making it easier to write automated tests.
Conclusion
Creating a service object and calling it in the mutation of GraphQL in Rails is an important clean code tip. It helps keep the code clean and organized and allows the logic to be reused in different application parts. When creating and calling the service object, it is important to consider the inputs and outputs, purpose, expected behavior, and expected outcome.
This code is part of the public repo of project twitter_clone_ruby_gql
, which I am working on these days. Link: https://github.com/sulmanweb/twitter-clone-ruby-gql.
Happy Coding!
Contact me at sulman@hey.com
Top comments (3)
Really good article ! Do you think that it is always worth it to create a service layer for your queries in graphql?
I feel that often we just fetch a model with find or something like this, and it will be much work to do it like this.
Of course when you have more than just boilerplate code it's worth.
The more I gain experience in coding, the more I feel like it is always a tradeoff. 👀
What do you think :D
You are right sometimes it looks like more than the required effort. But this is code in progress l. So in future if I need more validations, some extra service to be called like subscribing user to newsletters, or send email to user, or sending slack message when user signs up then business logic separation would look very nice. And most importantly when repo will get a lot big then this separation of models actions and business logic separation will look very beautiful and easy for onboarding engineers. Thinking of long term project makes it worth it in my opinion.
Of course I understand, like I said I think it is a question of tradeoff.
What has the most value in your opinion, I mostly agree with you for the developper experience of the future onboarded.