Organize your GraphQL files in a Phoenix project
Learn how How to structure a GraphQL schema in a Phoenix project.
If it's your first time working with GraphQL and Phoenix, I recommend having a look at Creating GraphQL APIs Using Elixir Phoenix and Absinthe as a great starting point.
The first steps
A few weeks ago I started using GraphQL in a Phoenix project. Following the good tutorial mentioned above, the first steps were simple: import absinthe, define the route, write your first query, write your first mutation, finally the resolver, and you are ready to start using GraphQL in your project. But while I was writing the third mutation, I began to have the feeling that my schema would become a huge monster. I decided not to feed it until I found a good file structure for my types, queries, mutations, and resolvers.
Looking for a good folder structure
I didn't want to define all my queries and mutations in the same file. In Empresaula, we use different folders for each schema object
so I tried to adapt my schema to this structure. After a bit of research, I found my two great allies for this journey.
import_types
: schema will be able to resolve any references to your custom typesimport_fields
: schema will be able to resolve any references to your custom fields
Now I was able to import types
and fields
from external modules. Using these two methods, I started organizing my GraphQL files in four folders.
Types: Where I define all the types
and inputs
for my queries and mutations
Queries: Where I create a file with the queries
for each context
Mutations: Where I create a file with the mutations
for each context
Resolvers: Where I create a file with the resolvers
for each context
Examples for each folder
Here is an example of a Type context:
defmodule SmartfootballWeb.Schema.Types.Match do
use Absinthe.Schema.Notation
alias SmartfootballWeb.Schema.Resolvers
object :match do
field :id, :id
field :opponent, :string
field :full_name, :string, resolve: &Resolvers.Match.full_name/3
...
end
input_object :input_match_params do
field :day, :string
field :home, :boolean
field :league, :boolean
field :opponent, :string
field :season_id, :id
field :lineups, list_of(:input_lineup_params)
field :events, list_of(:input_event_params)
end
end
A Query context:
defmodule SmartfootballWeb.Schema.Queries.Match do
use Absinthe.Schema.Notation
alias SmartfootballWeb.Schema.Resolvers
object :match_queries do
@desc "Find Match"
field :match, type: :match do
arg(:id, non_null(:id))
resolve &Resolvers.Match.get_match/3
end
end
end
A mutation context:
defmodule SmartfootballWeb.Schema.Mutations.Match do
use Absinthe.Schema.Notation
alias SmartfootballWeb.Schema.Resolvers
object :match_mutations do
@desc "Creates a match"
field :create_match, type: :match do
arg(:match, :input_match_params)
resolve &Resolvers.Match.create/2
end
@desc "Updates a match"
field :update_match, type: :match do
arg(:id, non_null(:id))
arg(:match, :input_match_params)
resolve &Resolvers.Match.update/2
end
@desc "Deletes a match"
field :delete_match, type: :match do
arg(:id, non_null(:id))
resolve &Resolvers.Match.delete/2
end
end
end
An example of a Resolver context:
defmodule SmartfootballWeb.Schema.Resolvers.Match do
alias Smartfootball.Repo
def get_match(_parent, _args = %{id: id}, _info) do
{:ok, Smartfootball.Seasons.get_match!(id)
|> Repo.preload(events: [:main_player, :secondary_player])
|> Repo.preload(:lineups)
}
end
def create(%{match: match_params}, _info) do
Smartfootball.Seasons.create_match(match_params)
end
def update(%{id: id, match: match_params}, info) do
case get_match(nil, %{id: id}, info) do
{:ok, match} -> match |> Smartfootball.Seasons.update_match(match_params)
{:error, match} -> {:error, match.errors }
end
end
def delete(%{id: id}, info) do
case get_match(nil, %{id: id}, info) do
{:ok, match} -> Smartfootball.Seasons.delete_match(match)
{:error, match} -> {:error, match.errors }
end
end
def full_name(parent = %{home: true}, _args, _resolution) do
parent = parent |> Repo.preload(season: [team: :club])
{:ok, "#{parent.season.team.club.name} - #{parent.opponent}"}
end
def full_name(parent = %{home: false}, _args, _resolution) do
parent = parent |> Repo.preload(season: [team: :club])
{:ok, "#{parent.opponent} - #{parent.season.team.club.name}"}
end
...
end
And finally in the schema.ex
we can import all the types, queries, and mutations.
defmodule SmartfootballWeb.Schema do
use Absinthe.Schema
# Types
...
import_types SmartfootballWeb.Schema.Types.Match
...
# Queries
...
import_types SmartfootballWeb.Schema.Queries.Match
...
# Mutations
...
import_types SmartfootballWeb.Schema.Mutations.Match
query do
...
import_fields :match_queries
...
end
mutation do
...
import_fields :match_mutations
...
end
end
And finally call the mutation
mutation createMatch {
createMatch(match: input_match_params){
id
fullName
}
}
At this point, I had a good folder structure to manage all the GraphQL files. You could even create a folder for inputs, but in my case, it was not necessary because there was only an input for each type.
Improving the fields structure
But I still had one problem to solve. Although queries and mutations are defined in different files, actually at the time of importing they are all at the same level. That is why I have to look for different names for each query or mutation. My first solution was writing the context name after the method name. For example: update_match
or update_season
.
But I was not very satisfied with this solution, so googling a little, I found a good post (Absinthe Tips and Tricks by Maarten van Vliet) and I decided to adopt the self
solution to the queries and mutations.
I defined a common resolvers module where I put all the common methods. The first common method was the itself
function. Only gets the parent and returns it to their children.
defmodule SmartfootballWeb.Schema.Resolvers.Common do
def itself(parent, _, _) do
{:ok, parent}
end
end
After the resolver was created, I changed the mutations to the new structure. It was very simple: you just need to create a new field that is resolved by the itself
method and return all the grouped fields created before.
Here is the code of my final mutations for the match context:
defmodule SmartfootballWeb.Schema.Mutations.Match do
use Absinthe.Schema.Notation
alias SmartfootballWeb.Schema.Resolvers
object :match_mutations do
@desc "Mutations for the matches"
field :matches, non_null(:match_mutations_methods), resolve: &Resolvers.Common.itself/3
end
object :match_mutations_methods do
@desc "Creates a match"
field :create, type: :match do
arg(:match, :input_match_params)
resolve &Resolvers.Match.create/2
end
@desc "Updates a match"
field :update, type: :match do
arg(:id, non_null(:id))
arg(:match, :input_match_params)
resolve &Resolvers.Match.update/2
end
@desc "Deletes a match"
field :delete, type: :match do
arg(:id, non_null(:id))
resolve &Resolvers.Match.delete/2
end
end
end
And now I can call my Queries
and Mutations
using the context name:
mutation createMatch {
matches {
create(match: input_match_params) {
id
fullName
}
}
}
Conclusions
I've tried to jail the monster before it was too big. At the moment this organization helps me to keep it nice and clean. But the project isn't big enought yet to confirm that's a good organization. Maybe some problems will appear in the near future.
I hope this post has been helpful for your schema organization. If you have any comments, questions or suggestions, tell us on Twitter!
Photo by Paul Hanaoka on Unsplash