Decoupling client and server responsibilities in a Ruby GraphQL server

Rohin Daswani
Project Ronin
Published in
3 min readDec 10, 2018

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:

  1. Define types, and appropriate queries/mutations for them
  2. Implement functions called resolvers to handle these types and their fields
  3. 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 userswhich has the fields first_name, last_name, gender, image_url and address. The task is to add a JSON column calledpreferencesto the users table that captures the following information:

  1. Would the user like to enable push notifications
  2. Notification frequency
  3. Notification time
  4. 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: true
end

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.

--

--

Rohin Daswani
Project Ronin

Engineering Manager @Carta | Ex. Software Engineer @Project Rōnin | RoR Enthusiast