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
endEvery 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
endNote 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
endResolving a mutation was as simple as:
def resolve(some_arg:)
Some::Service.call(some_arg: some_arg)
endWe 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
endSo we added the following:
module OysterService
# ...
private
def service_step
yield unless errors.present?
end
endAnd 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
endSo 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 }
endIt'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_}
endThat, 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)
endand the following implementation
attr_reader :invoice
def process
@invoice = Invoice.create(company_id: company_id)
errors << @invoice.errors.full_messages
# ... other stuff
endIf 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?
endThis is very common, and can get repetitive.
service_attr_reader :invoice
def process
@invoice = Invoice.create(company_id: company_id)
# ... other stuff
endAnd 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- 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.