One of the great things about GraphQL is how broad the abstractions are. Everything is just an “object”, with “fields”! This makes things infinitely composable, and is where a good deal of the power in GraphQL comes from. But, like all things, there are downsides to this - first and foremost (in my opinion) is that it makes organizing a project difficult. Because you can do just about anything, you might end up doing that, and having a fairly difficult time of finding things and knowing where to put stuff. But, most importantly, this can make talking about GraphQL applications difficult since some of these terms are really murkey.
I’ve worked on 3 GraphQL projects now, and I think I’ve settled on the structure and nomenclature that I really like, and that’s what I’m going to present today. But, before I dive into it, we need to get some terms defined up front. Every time I use one of these terms later on, this is what I’m referring to.
- type - This is the definition of a type (using
objectin Absinthe). These defintions usually look something like this:
- input object - This is a type, but it’s kind of special since it is used to define the arguments given to an operation (if any are needed). They look just like objects, but use a different macro for definition:
- schema - the last thing we needed was another thing in our application called a “schema” (since database schemas and Ecto schemas already a thing), but here we are. When you’re talking about these with your team, it’s helpful to always say stuff like “Ecto schema” or “Absinthe schema” to avoid confusion. These schemas define the interface for our API, and are made up of “queries”, “mutations” and “subscriptions”, but we break them up into chunks for certain domain concepts in our application to make them easier to work with, and those usually look like this:
Ok, so with that out of the way, we’re going to use the cannonical blog post example for our
domain today, with
The directory structure
First off, this post isn’t going to cover much in the
blog context, as I’ve already covered my
thoughts on that stuff before. But, this is what the general
directory structure would look like (minus the normal stuff in every project like config and
I think the general idea there is pretty clear - we separate
into their own directories. Each type should have a corresponding resolver, and if there are
queries, mutations or subscriptions for those types, they belong in their own file. Now, onto
what’s in each section!
types directory, we have a file for each type - but that file can contain more than one
type definition! We also put our input_objects in here, since that’s the best place to keep
these sorts of things. For example, in
blog_web/types/user.ex, we could have something like
These are all of our user-related types, and so we can put them all there. If we needed more specific objects or input objects related to users, we could also put them here.
What we shouldn’t have in here are any actual resolver functions - all definitions for all resolvers should be in the associated resolver module. No using anonymous functions in here, even if they’re just a couple lines! Keeping things consistent means always using one thing or the other, and since always using anonymous functions for resolvers wouldn’t work at all, never using them is the only remaining option.
I really wish I had a better word for these things (maybe folks can call them schema fragments?),
but what goes in
blog_web/schemas are essentially decomposed parts of one large schema, defined
blog_web/schema.ex. So, we chunk that one huge schema up into smaller pieces, and those
pieces look something like this (for example,
In there we’ve defined our queries relating to users, as well as documentation and resolution functions for those queries. The same restrictions go for mutations and subscriptions.
Oh, and one other thing - try and make sure that if a query, mutation or subscription need any
arguments that they take a single input, and that input is a previously defined
like we’ve done above with our
Once we have all those bits and pieces, we put it together in a
blog_web/schema.ex file like so:
If you’re consistent enough with naming and such you could potentially even write a nice little macro to take care of all that for you, but please don’t jump right to that since macros can be a huge pain to maintain over time.
Ok, just really quick on this one. Resolver functions are tested just like any other function, and
those tests look like this (in
Then, our schemas are tested in files that mirror the way they’re defined, so we test user queries
test/blog_web/schemas/queries/user_test.exs and comment mutations in
test/blog_web/schemas/mutations/comment_test.exs. The describe blocks in those tests are for
each query/mutation/subscription under test, and look like this:
So, we’re testing two things there - the response to our client, and the side effects! I’ve seen so many times where just the response is tested in these kinds of tests, but the side effects (usually what happens in the database) aren’t tested at all, and that’s super dangerous.
Usually one of these tests for the happy path and one test for some sort of expected failure (like querying for a user that doesn’t exist, or an authorization error or something) is enough at this level, and then you can test more specific cases further down the stack.
The tradeoffs here are that we have a lot of files. Some people don’t like having a lot of files,
and instead of having files like
blog_web/schemas/mutations/user.ex, they would just want
blog_web/schemas/user.ex with all the
schemas for that domain concept in there.
I don’t like that because then the corresponding test file would be absolutely massive, and I
think it’s worth having the consistency of test files matching source files. Once you end up with
that thing that has like 15 mutations on it (usually
user, but there’s always something), then
you start breaking stuff apart, and you lose the consistency. I’d rather err on the side of
consistency than on the side of conveniences for 95% of cases that don’t cover the other 5%.