Decoupling client and server responsibilities in a Ruby GraphQL server
Here at Project Ronin, I work on our APIs that our client applications use to send and receive data. This was my first foray from REST to GraphQL and found quite a few interesting observations and learnings during the transition. I’m working on a 3 part series that highlights some of the not so obvious traps that RESTful engineers may find themselves in when building APIs in GraphQL. GraphQL is designed around Schema-driven development. The emphasis is on formalizing the contract between the server and the client by having well-defined schemas.
The main development process behind building a GraphQL server will revolve around the schema definition. The steps will look like:
- Define types, and appropriate queries/mutations for them
- Implement functions called resolvers to handle these types and their fields
- As new requirements arrive, go back to step 1 to update the schema and continue through the other steps
The schema is a contract agreed on between the frontend and the backend. Keeping the contract at the center lets the client and the server evolve separately.
When starting off with GraphQL, it is really easy to create a tight coupling between the GraphQL exposed types and the server’s data model. This approach, however, isn’t sustainable for applications whose data model is more complex than a blogging service since the server needs to structure its data in a way that maximizes and future proofs the services it is rendering to the one (or many) applications it is responsible for.
Let’s explain with an example. Suppose the server has a data model for users
which has the fields first_name
, last_name
, gender
, image_url
and address
. The task is to add a JSON column calledpreferences
to the users
table that captures the following information:
- Would the user like to enable push notifications
- Notification frequency
- Notification time
- Current time zone
Adding this column is fairly straightforward in Ruby On Rails. However, we need to keep in mind that exposing a JSON type in GraphQL defeats the purpose of a schema contract between the client and the server since the client doesn’t have clarity on the keys contained in the JSON type and hence cannot make effective use of the API.
Instead of having the User
type expose the JSONpreferences
field, you should follow the strategy outlined below.
class Types::UserType < Types::BaseObject
field :id, ID, null: false field :first_name, String, null: false
field :last_name, String, null: false
field :address, Types::DateType, null: true
field :gender, String, null: true
field :image_url, String, null: true
field :preferences, Types::UserPreferencesType, null: true field :created_at, Types::DateTimeType, null: true
field :updated_at, Types::DateTimeType, null: true
end
The type used for the preferences
field is outlined below.
class Types::UserPreferencesType < Types::BaseObject
field :enable_push_notifications, Boolean, null: true
field :notification_frequency, String, null: true
field :notification_time, Types:TimeType, null: true
field :time_zone, String, null: trueend
The type is defined but the server doesn’t know how to resolve the query with the required data. This is done by defining a class called UserPreferences
that has accessor methods for each of the fields defined above. Each field of your GraphQL type needs a corresponding resolver function. When a query arrives at the backend, the server will call those resolver functions that correspond to the fields specified in the query.
The User
model class needs to have a method matching the name of the field defined in the UserType
. The method creates a new instance of UserPreferences
and returns it. GraphQL now knows the type of the returned object and looks for the corresponding accessor functions defined on UserPreferences
. The code below walks you through the 2 steps.
class User < ApplicationRecord
delegate :enable_push_notifications, :notification_frequency, :notification_time, :time_zone, to: :user_preferences, allow_nil: true
def preferences
UserPreferences.new(preferences)
end
end
UserPreferences
looks like
class UserPreferences
def initialize(data)
@metadata = data
end
def enable_push_notifications
@metadata.dig("enable_push_notifications")
end
def notification_frequency
@metadata.dig("notification_frequency")
end
def notification_time
@metadata.dig("notification_time")
end
def time_zone
@metadata.dig("time_zone")
end
end
And that’s it. This strategy decouples the server’s data modeling from the client’s needs since the server can now extend the User
model in any direction it needs to. If it needs to move UserPreferences
into it’s own table it can. If it wants to split UserPreferences
into 2 separate tables it can do that as well by swapping out the return values in the UserPreferences
model. This nice abstraction gives the server and client enough flexibility to know each other’s needs without hindering their individual responsibilities.
In the next few articles, I’m going to delve deeper into building loosely coupled client-server GraphQL interactions.