How I Architect My Graphene-Django Projects
Recently at work Iβve been working quite a bit with Django and GraphQL. There doesnβt seem to be much written about best practices for organizing your Graphene-Django projects, so Iβve decided to document whatβs working for me. In this example I have 3 django apps: common, foo, and hoge.
Thereβs two main goals for this architecture:
- Minimize importing from βoutsideβ apps.
- Keep testing simple.
Queries and Mutations Package
Anything beyond simple queries (i.e. a query that just returns all records of a given model) are implemented in their own file in the queries or mutations sub-package. Each file is as self-contained as possible and contains any type definitions specific to that query, forms for validation, and an object that can be imported by the app's schema.py
.
Input Validation
All input validation is performed by a classic Django form instance. For ease of use django form input does not necessarily match the GraphQL input. Consider a mutation that sends a list of dictionaries with an object id.
{
"foos": [
{
"id": 1,
"name": "Bumble"
},
{
"id": 2,
"name": "Bee"
]
}
Before processing the request, you want to validate that the ids passed actually exist and or reference-able by the user making the request. Writing a django form field to handle input would be time consuming and potentially error prone. Instead each form has a class method called convert_graphql_input_to_form_input
which takes the mutation input object and returns a dictionary that can be passed the form to clean and validate it.
from django import forms
from foo import models
class UpdateFooForm(forms.Form):
foos = forms.ModelMultipleChoiceField(queryset=models.Foo.objects)
@classmethod
def convert_graphql_input_to_form_input(cls, graphql_input: UpdateFooInput):
return { "foos": [foo["id"] for foo in graphql_input.foos]] }
Extra Processing
Extra processing before save is handled by the form in a prepare_data
method. The role this method plays is to prepare any data prior to / without saving. Usually I'd prepare model instances, set values on existing instances and so forth. This allows the save()
method to use bulk_create()
and bulk_update()
easily to keeps save doing just that - saving.
Objects/List of objects that are going to be saved / bulk_created / updated in save are stored on the form. The list is defined / set in init with full typehints. Example:
from typing import List, Optional
class UpdateFooForm(forms.Form):
foos = forms.ModelMultipleChoiceField(queryset=models.Foo.objects)
def __init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.foo_bars: List[FooBar] = []
self.bar: Optional[Bar] = None
Type Definition Graduation
Types are defined in each query / mutation where possible. As schema grows and multiple queries/mutations or other app's queries/mutations reference the same type, the location where the type is defined changes. This is partially for a cleaner architecture, but also to avoid import errors.
βββ apps
βββ common
β βββ schema.py
β βββ types.py # global types used by multiple apps are defined here
βββ hoge
βββ mutations
β βββ create_hoge.py # types only used by create_hoge are in here
β βββ update_hoge.py
βββ queries
β βββ complex_query.py
βββ schema.py
βββ types.py # types used by either create/update_hoge and or complex_query are defined here
Example Mutation
The logic kept inside a query/mutation is as minimal as possible. This is as it's difficult to test logic inside the mutation without writing a full-blown end-to-end test.
from graphene_django.types import ErrorType
class UpdateHogeReturnType(graphene.Union):
class Meta:
types = (HogeType, ErrorType)
class UpdateHogeMutationType(graphene.Mutation):
class Meta:
output = graphene.NonNull(UpdateHogeReturnType)
class Arguments:
update_hoge_input = UpdateHogeInputType()
@staticmethod
def mutate(root, info, update_hoge_input: UpdateHogeInputType) -> str:
data = UpdateHogeForm.convert_mutation_input_to_form_input(update_hoge_)
form = MutationValidationForm(data=data)
if form.is_valid():
form.prepare_data()
return form.save()
errors = ErrorType.from_errors(form)
return ErrorType(errors=errors)
Adding Queries/Mutations to your Schema
This architecture tries to consistently follow the graphene standard for defining schema. i.e. when defining your schema you create a class Query
and class Mutation
, then pass those to your schema schema = Schema(query=Query, mutation=Mutation)
Each app should build its Query and Mutation objects. These will then be imported in the schema.py, combined into a new Query class, and passed to schema.
# hoge/mutations/update_hoge.py
class UpdateHogeMutation:
update_hoge = UpdateHogeMutationType.Field()
# hoge/mutations/schema.py
from .mutations import update_hoge, create_hoge
class Mutation(update_hoge.Mutation,
create_hoge.Mutation):
pass
# common/schema.py
import graphene
import foo.schema
import hoge.schema
class Query(hoge.schema.Query, foo.schema.Query, graphene.GrapheneObjectType):
pass
class Mutation(hoge.schema.Mutation, foo.schema.Mutation, graphene.GrapheneObjectType):
pass
schema = graphene.Schema(query=Query, mutation=Mutation)
Directory Tree Overview
βββ apps
βββ common
β βββ schema.py
β βββ types.py
βββ foo
β βββ mutations
β β βββ create_or_update_foo.py
β βββ queries
β β βββ complex_foo_query.py
β βββ schema.py
βββ hoge
βββ mutations
β βββ common.py
β βββ create_hoge.py
β βββ update_hoge.py
βββ queries
β βββ complex_query.py
βββ schema.py
βββ types.py