Skip to content

Instantly share code, notes, and snippets.

@khamusa
Last active June 12, 2021 15:39
Show Gist options
  • Save khamusa/e9a44b7f0a9a20d73b51610d2885237b to your computer and use it in GitHub Desktop.
Save khamusa/e9a44b7f0a9a20d73b51610d2885237b to your computer and use it in GitHub Desktop.
Service layer

In my last company we (I, mostly) made way too many design mistakes in our first attempt at implementing a GraphQL api. We wanted to make sure we'd not repeat ourselves in the next one.

One of the first things we did was to ensure every mutation used the same convention for exposing errors. We defined the following on the base mutation payload type:

module Types
  class BaseMutationPayload < Types::BaseObject
    field :errors, [String], null: false
  end
end

Every Single Mutatio would follow this api, no exceptions. The main benefits being predictability and familiarity both for backend and frontend developers.

So, we wanted to have a service layer that plugged easily into our graphql mutation resolution layer without any explicit nor implicit dependencies. So we started with something like this:

module OysterService
  extend ActiveSupport::Concern

  class_methods do
    def call(*args, **kwargs)
      new(*args, **kwargs).tap(&:process)
    end
  end

  def errors
    @errors ||= []
  end
end

Note the usage of #tap to ensure we always return the created service instance itself.

No additional code is required to plug the service errors output to the mutation:

class Some::Service
  include OysterService

  def process
    # ...
    
    errors << "That's a bad input"
    
    # ...
    # return value does not matter
  end
end

Resolving a mutation was as simple as:

def resolve(some_arg:)
  Some::Service.call(some_arg: some_arg)
end

We thought it was very easy to use and understand, explicit, with minimal API and no DSL. While it plugs well with the GraphQL layer, it's independent. Testing requires little to no scaffolding / plumbing code.

Most of our #process methods ended up looking like this:

  def process
    do_something

    # Those two should only run in case we have not errored
    do_something_else unless errors.present?
    do_another_thing unless errors.present?
  
    # This should always run
    something_complex do
      more_stuff_here
    end

    return if errors.present?

    this_only_runs_on_success
  end

So we added the following:

module OysterService
  # ...
  private

  def service_step
    yield unless errors.present?
  end
end

And the previous implementation became:

  def process
    do_something

    service_step { do_something_else }
    service_step { do_another_thing }
    something_complex { more_stuff_here }
    service_step {  this_only_runs_on_success
  end

So service_step will ensure the inner block does not run if we have errors.

Composing services can be achieved by using services from a parent composer service:

  def process
    creation_result = Some::Suject::Create.call(params: 123)
    errors.concat(creation_result.errors)

    service_step do
      processing_result = Some::Subject::Processing.call(subject: creation_result.subject)
      errors.concat(processing_result.errors)
    end

    service_step { will_not_run_if_other_services_failed }
  end

It's straightforward, explicit, but cumbersome. With another thin framework layer it could become:

  def process
    creation_result = use_service { Some::Suject::Create.call(params: 123) }

    use_service { Some::Subject::Processing.call(subject: creation_result.subject) }

    service_step { will_not_run_if_other_service_}
  end

That, if we decide we want to have this kind of composition as a common practice, otherwise we could skip the addition of #use_service.

Now, consider a mutation defined as:

argument :company_id, ID, required: true, loads: Types::CompanyType

field :invoice, Types::Invoice::InvoiceType, null: true

def resolve(company_id:)
  Invoice::Create.call(company_id: company_id)
end

and the following implementation

attr_reader :invoice

def process
  @invoice = Invoice.create(company_id: company_id)
  errors << @invoice.errors.full_messages

  # ... other stuff
end

If a validation error occur, the attr_reader will still expose the invoice. Since InvoiceType has a non-nullable id field (which usually is requested on the payload of a create mutation), that would cause a type error on the graphql layer.

The obvious solution:

def invoice
  @invoice unless errors.present?
end

This is very common, and can get repetitive.

service_attr_reader :invoice

def process
  @invoice = Invoice.create(company_id: company_id)
  # ... other stuff
end

And that declares the #invoice method for us, mimicking ruby's native attr_reader syntax and guarding against service errors.

The final implementation of our framework:

module OysterService
  extend ActiveSupport::Concern

  class_methods do
    def call(*args, **kwargs)
      new(*args, **kwargs).tap(&:process)
    end

    def service_attr_reader(*attrs)
      attrs.each do |attr|
        class_eval <<-RUBY, __FILE__, __LINE__ + 1
          def #{attr}
            return nil if errors.present?
            @#{attr}
          end
        RUBY
      end
    end
  end

  def errors
    @errors ||= []
  end

  private

  def service_step
    return if errors.present?

    yield
  end

  # optional
  def use_service
    service_step do
      yield.tap { |result| errors.concat(result.errors) }
    end
  end
end

What tests did we write?

  • We test our graphql schema fields using https://github.com/khamusa/rspec-graphql_matchers
  • Test each and every single service object;
    • For services that composed other services together, we'd often only test the communication between them, avoiding the overhead of creating database objects, mocking network requests, etcettera;
  • We wouldn't test the mutation resolvers, since:
    • It's cumbersome, requiring lots of mocking;
    • There shouldn't be business logic on the API layer;
    • The only responsibility of a resolver was to delegate the resolution to some other entity / service.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment