For the past few months, we have been working on a project for a hotel management system. Their stack consisted of a Ruby on Rails backend and a Typescript and React frontend. Their API, instead of a traditional RESTful approach, was implemented with GraphQL.
What’s GraphQL?
GraphQL was created as an alternative to REST to address common challenges like data over- and under-fetching. It originated from Facebook’s need to enhance performance and flexibility in its applications. GraphQL allows client applications to precisely request the data they need without being constrained by pre-defined server endpoints. This allows developers to build customized queries, optimize data retrieval, and improve efficiency.
There are four main components involved in its functionality:
- Schema: Defines the operations that the API will support, including Types, Queries, and Mutations.
- Types: Specify what entity data will be visible and in which format.
- Queries: Operations to retrieve data.
- Mutations: Operations to modify data.
Schema
Serves as the contract between the client and the server, defines what and how clients can request or modify the data ensuring that both “speak the same language”. It uses a strongly typed system, meaning that every field and its arguments are explicitly defined, preventing common errors like requesting invalid fields. Additionally, since requests are validated against the schema at runtime, it ensures that the data requested is valid and well-formed.
The schema can be generated automatically based on the application data models (and it’s the most common approach). In our case, we choose to define everything manually and using graphql-ruby. Manual schema definition can be useful for fine-tuning the API behavior, adding custom validations, and optimizing the response structure, allowing us to have granular control over the exposed data.
Types
Types define the structure of the data available in the API. It specifies the fields, their data types, and their relationships with other types, enabling precise queries and predictable responses while also providing clear documentation. An example type in graphql-ruby:
class Types::UserType < Types::BaseObject
field :id, ID, null: false
field :access_locked, Boolean, null: false
field :email, String, null: false
field :first_name, String, null: true
field :has_special_suite_access, Boolean, null: false
field :last_active, GraphQL::Types::ISO8601DateTime, null: true
field :last_name, String, null: true
field :roles, [Types::RoleType], null: false
def access_locked
object.access_locked?
end
def has_special_access
object.has_special_access(current_brand)
end
def roles
object.roles_in_brand(current_brand)
end
end
Types::UserType
represents a User
object and specifies the fields that can be queried, such as email
, first_name
, last_active
, and roles
. Each field is defined with its name, data type, and whether it can be null.
Pay attention to the Roles
field, it’s an array of a type called Types::RoleType
, demonstrating how types can reference other types and manage complex relationships.
Fields also can have custom resolver methods, such as roles, which retrieve brand-specific roles for the user.
Queries
Queries define how data is retrieved from the API. An example in graphql-ruby:
class Queries::UsersQuery < GraphQL::Schema::Resolver
# arguments
type Types::UserType, null: false
def before_prepare
#...
end
def resolve
User.all
end
end
The resolve
method contains the core logic to fetch the data. In this example, it retrieves all the user records. The method before_prepare
can be used to perform certain actions or validations before resolving the query. Note the schema strict type definition making use of the previously defined UserType
.
With this type defined, we can use any graphql client in the frontend and make a call to this query using gql (graphql query language):
query users() {
users() {
user {
id
email
firstName
lastName
}
}
}
This will fetch all users, and for every user, it will return its id
, email
, firstName
, and lastName
fields. One of the most powerful features of GraphQL is that you can specify which fields you want to receive from the backend in each query, and that’s not only restricted to a type. You can build different queries for the same type and include relationships, relationships within relationships, etc. But be careful, the more complex the queries to the backend, the more complex the queries to the database. This could become very slow very easily.
Mutations
Mutations define operations that modify data on the server. These are similar to queries but are used for mutating the data, ie creating, updating, or deleting data. An example in graphql-ruby:
class Mutations::UpdateUser < Mutations::BaseMutation
argument :id, ID, required: true
argument :email, String, required: false
field :user, Types::UserType, null: false
def before_prepare(**args)
set_user args[:id]
authorize user, :update?
end
def resolve
# Update user custom logic
{ user: user }
end
end
In this example, it defines two arguments: a required id
and an optional email
. These arguments specify the input needed to perform the update operation. The field declaration indicates that the mutation will return a UserType
, ensuring that the result adheres to the structure defined. The before_prepare
method is used for pre-processing and checking whether the current user is authorized to perform the update.
Mutations are very similar to Queries, in this case, there’s a slight difference:
mutation updateUser($id: ID!, $email: String) {
updateUser(
input: {
id: $id,
email: $email
}
) {
user {
id
email
}
}
}
While this may seem repetitive, we have to write the arguments in the first function and declare their types to ensure that they match the GraphQL schema, and inside the user method, we include an input object with these arguments. Notice how inside brackets we define what information will be returned after the mutation is run, in this example we only want the id
and the email
fields. As with queries, you can design the output object as you please.
Working on the client side with Apollo
We have our query defined, but how do we use it? We could simply use the Fetch API function and send the syntax in the body of the request as a JSON string to the GraphQL endpoint, but this would be too complicated to manage. Instead, we use a library like Apollo.
Apollo Client is a tool that made working with GraphQL on the client side a lot easier, this library provides some interesting features out of the box:
- Includes React hooks to handle queries and mutations, these return useful information like the loading state, an array of errors, the network status, and so on.
- It has local state management built in, with the cache being the single source of truth for all of our data. This saves us the hassle of working with state management libraries like Redux or MobX.
- It also has a browser extension, Apollo Client DevTools, which enables us to view the list of available queries and mutations, inspect the cache, and more.
There are some other tools that serve the same purpose, like Relay or Urql, but Apollo has a wider compatibility and a bigger community.
Example of useQuery
Let’s go back to the users
query, the first thing we have to do is to define it inside the gql function from Apollo:
import { gql } from "@apollo/client";
export const GET_USERS = gql`
query users() {
users() {
user {
id
fullName
createdAt
}
}
}
`;
and then we pass this function to the useQuery
hook.
const getUsers = () => {
const { data, loading, error } = useQuery(GET_USERS);
return {
users: data,
loading
};
};
Differences with REST
REST is a broad and complex topic that is out of the scope of this article, but we think a brief comparison with graphql is pertinent.
Technically, a REST API is a web service interface that complies with the constraints of the architecture for distributed hypermedia systems called REST (Representational State Transfer). However, colloquially it is used to refer to web services that are built on top of HTTP, use some kind of JSON-based media type, and URLs are generally resource-oriented (like the WWW).
GraphQL | REST API |
---|---|
Only one endpoint for all the operations (/graphql ). |
Multiple endpoints for each resource (/user , /posts , /user/:id/posts ). |
Queries made in a format defined by the client (GraphQL Query Language). | HTTP standard requests, with predefined responses. |
The client can specify which data wants to retrieve. | The server returns fixed responses, sometimes with unnecessary extra data [1]. |
It doesn’t require versioning; clients only work with the data they need. | Generally, they require versioning to maintain compatibility (/v1/users , /v2/users ). |
Complex relationships: Allows nesting requests and querying related data in a single query. | Complex relationships: Might require multiple calls to different endpoints to handle relationships. |
[1] Note: currently there are media types widely adopted like jsonapi that support Sparse Fieldsets which addresses part of this issue, but the implementation is not standard.
Pros
Data integrity
As mentioned above, GraphQL provides a schema for all of the types and operations available. If you’re trying to query or mutate data that’s not in the schema you will get an error.
Ideal for working with relationships
As the name implies, GraphQL presents our results in a graph-based schema, making it ideal for solving the N+1 requests problem. For example, let’s say we have a posts
table inside our database, and a relation to another table called comments
, in GraphQL we can access this relation directly in our queries and compose the response as we see fit. Not only can we fetch the comments of a post, but for each comment, we can also fetch the authors. This nesting results in an (N+1)^2 scenario. The interesting part is being able to design the response to fetch whatever we want without it necessarily existing as an operation. Doing this with a REST API is a bit more complicated, as we would require multiple requests on the client-side, or more work on the server-side logic to aggregate the data.
The N+1 queries problem still needs to be handled by the backend based on the database structure, as a tradeoff for reduced latency and larger request sizes.
query post($id: ID!) {
post(id: $id) {
id
title
comments {
id
createdAt
author {
id
fullName
}
body
}
}
}
Cons
Steep learning curve
The syntax and functionalities of GraphQL can be more complex than on a regular REST API, and to handle these operations we require extra tools on the client-side to make working with these APIs less painful, like Apollo or Relay.
Conclusion
In the project’s beginning, most operations were CRUD-like. Using a REST API seemed a better fit for the project as we weren’t using the good features of GraphQL, and things like the cache were making things much more complicated than they normally are. But then the database started growing and more complex operations were needed in the frontend. GraphQL started to make sense, and we could say it was worth sacrificing a little extra time in learning how to use it, instead of building complex things in REST.
REST is widely used. It’s simple, scalable, and relies on standard HTTP methods, making it intuitive and compatible with a huge range of tools and systems. However, REST structure can lead to over-fetching and under-fetching data, especially in complex applications with interconnected data. In contrast, GraphQL offers more flexibility allowing clients to request specific data, often reducing the number of requests and it shines in scenarios with complex data relationships or dynamic requirements.
You should use GraphQL when:
- You have decoupled frontend and backend teams or need frontend autonomy.
- The application requires flexible, dynamic queries across a complex or evolving schema.
- Your application retrieves data from multiple microservices or you’re implementing a Backend for Frontend (BFF) layer.
- Rapid UI updates or multiple client types are priorities.
You should use REST when:
- You are part of a small team.
- You’re building a system with well-defined, predictable data needs.
- Service teams own APIs independently and prioritize stability and simplicity.
The choice between REST and GraphQL depends on the application’s complexity, performance, and developer’s familiarity with the technologies. Both can be used to leverage their respective strengths. The organizational structure and collaboration within the technical team play a critical role in determining the effectiveness of any tool. Selecting the right approach should align with the project’s specific needs and team dynamics, as neither REST nor GraphQL is universally the best solution for all cases.