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:

  1. Minimize importing from “outside” apps.
  2. 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

Leave a Reply

Your email address will not be published. Required fields are marked *