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
- 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.