Clean Code Tip: Create a Service Object and Improve Your GraphQL Mutations in Rails

Learn how to create a service object and call it in the mutation of GraphQL in Rails. This clean code tip helps to make your code more modular and scalable, while promoting code reuse and reducing complexity. Read on to learn how to implement this tip and benefit from its advantages.

Clean Code Tip: Create a Service Object and Improve Your GraphQL Mutations in Rails
Clean Code Tip: Create a Service Object and Improve Your GraphQL Mutations in Rails

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

https://github.com/sulmanweb/twitter-clone-ruby-gql/blob/20bf99b2740dc059b68e258b1d6e69c21e7d19f0/app/services/users/signup_service.rb

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

https://github.com/sulmanweb/twitter-clone-ruby-gql/blob/20bf99b2740dc059b68e258b1d6e69c21e7d19f0/app/graphql/mutations/sign_up.rb

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