Organize your GraphQL files 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 types
  • import_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.

forders-structure-emaple

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