Django Forms as Domain Adapters

I have a secret to tell you. I shouldn’t be telling you because it is a secret, but it’s hard to keep it. It wants to go away. I’m not fighting anymore. Here it is: you can use django forms as domain adapters. That’s it.

I can pause now and start the story from the beginning.

Django Forms as Domain Adapters
Django Forms as Domain Adapters
This blog post first appeared on CAPSiDE's web site the 17th of December, 2018 under one of it's #capsideLAB articles. It was an honor for me to contribute it. Special mention to @whiskyemms, who aided me with the editorial process and also made the visual storyline of the post.

Let Me entertain You

I will use a simple user story as an example. Say we have a web application for keeping track of when workers are available. To have vacations you have to request your manager’s approval so that he can reschedule things and so on. This flow starts with a form for requesting vacations or days off. The form will need the following information:

  • a range of dates
  • a type of vacations (the same form can be used for requesting days off)
  • a fiscal year

Dealing with user input is a tedious task, at least for me. Fortunately, we’re using django.

Django is awesome. Try it. And Django forms is one of the nicest form frameworks I’ve ever seen. A glorious secret weapon. Don’t tell your enemies. Don’t tell your boss. Don’t even tell your wife. But try it!

Using django, a simple, clean and straightforward solution is the following.

Create a django model for the requests

class VacationsRequest(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    start_date = models.DateField()
    end_date = models.DateField()
    request_date = models.DateField()
    status = models.CharField(max_length=50, choices=STATUS_TYPES, default=PENDING)
    request_type = models.CharField(max_length=50, choices=REQUEST_TYPES, default=VACATIONS)
    fiscal_year = models.SmallIntegerField()

and construct a django form from it

class RequestVacationsForm(forms.ModelForm):

    class Meta:
        model = models.VacationsRequest
        exclude = ['user', 'request_date', 'status']

Django will handle the rendering, the parsing and the form data validation itself. It will even send the errors back to the user. Out of the box! Not bad for just a few lines of code. I told you it is awesome, didn’t I?

The only missing pieces are plumbing. Setting up the controller (view in django’s lingo)

def request_vacations(request):
    if request.method == 'POST':
        form = forms.vacations.RequestVacationsForm(request.POST, user=request.user)
    if form.is_valid():
        // ... process the request
        return redirect(reverse('my_vacations'))
    else:
        form = forms.vacations.RequestVacationsForm(user=request.user)
        return render(request, 'vacations/request_vacations.pug', {'form': form })

and rendering the form in a template

form(action="/vacations/request_vacations" method="post")
    {% csrf_token %}
    {{ form.as_p }}
    .row
        .col.m12.center
            input.btn(type="submit" value="Submit")
        {{ form.media }}

Django forms makes dealing with web forms a breeze.

Just to summarize, django here is silently helping you out with

  • rendering the form
  • parsing incoming post data
  • validating data types
  • validating required fields
  • reporting invalid data types or missing required fields

How cool is that?!

But this is a simple example. Real world applications tend to be more complex. Don’t worry, you’ll see that managing complexity is even cooler.

Django Forms as Domain Adapters
Django Forms as Domain Adapters

I’m Going Slightly Mad

This is nice and clean, but for me there is one major drawback. This is too low level.

It just worked because the task was easy. As the motto says,

simple things should be simple, but complex things should be possible.

We’re now going to complicate things a bit. We will restrict the form to only present valid values for the fiscal year and we will also validate the data against some business rules that go beyond correct data types and ranges (i.e., things that the framework doesn’t provide).

This use case is part of a larger application that has been developed using Domain Driven Design. We’ve been using DDD here at CAPSiDE for a while, and it has had a good impact in the development team.

One of the benefits of implementing a domain model is to keep the rules on the model. This is an architectural decision that assumes that is definitely better to have a rich domain that aggregates the business rules than having them implicitly spread through the controllers, views and all over the project.

This article is not about architecture per se, but it assumes that the whole code is architected following an hexagonal, ports and adapters or clean architecture. Mainly, the domain code resides in its own module and has no knowledge of django. Above that there is a layer of use cases. Then there are the adapters. For the persistence layer and for the UI. We’re talking here about the adapters of the user interface and how to keep the domain protected while scaling the UI functionality.

In the following, I will show you how you can use django forms as domain adapters. Specifically we will

  • restrict only valid values that come from the domain
  • apply business rules in the domain and propagate the domain errors back to the UI

And we will do that without increasing the complexity. We will not touch any plumbing from here on.

Getting back to our concrete example, say our business rules only allow for requesting vacations for the next year on the last month of the year. You can express that by code:

def fiscal_years_for_date(date):
    year = date.year
    if date.month > 11:
        return [year, year + 1]
    else:
        return [year]

Once this logic is part of the domain, we can test it. (This is what we’ve done first, right?)

class VacationsFiscalYearsTest(TestCase):

    def test_fiscal_years_include_current_year(self):
        date = random_date()
        years = vacations.fiscal_years_for_date(date)
        self.assertIn(date.year, years)

    def test_fiscal_years_include_next_year_in_december(self):
        date = datetime.date(2018,12,2)
        years = vacations.fiscal_years_for_date(date)
        self.assertIn(date.year + 1, years)

    def test_fiscal_years_dont_include_next_year_if_not_in_december(self):
        date = random_date()
        years = vacations.fiscal_years_for_date(date)
        self.assertNotIn(date.year + 1, years)

Those are unit tests, so there is no need for mocks, stubs, or extra paraphernalia. And they are fast to execute. If the logic changes–and it will–, both business and tests are easy to change because they’re not coupled to the application, UI or use case orchestration.

Note that this domain and test code is totally independent from django. It could effortlessly be used with flask or with a console application. The same way it is used in tests. This is the real benefit of adopting an hexagonal architecture. It might have a little extra cost at the beginning, but it pays off rapidly.

The good news is that you can keep these architectural benefits while using django. In the context of hexagonal architecture, you can use django forms as domain adapters.

Django Forms as Domain Adapters
Django Forms as Domain Adapters

Using Django Forms as Domain Adapters—It’s a Kind of Magic

While django is very opinionated and has some nice defaults, it stays out of your way too. It is easy to modify slightly django forms to restrict the fiscal year to present only valid values. Django forms are classes, so you can add some little code to customize them:

class RequestVacationsForm(forms.ModelForm):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['fiscal_year'].widget.choices = self.fiscal_years_choices()

    class Meta:
        model = models.VacationsRequest
        exclude = ['user', 'request_date', 'status']
        widgets = {
            'fiscal_year': widgets.Select(),
        }

    def current_date(self):
        return datetime.date.today()

    def fiscal_years_choices(self):
        current_date = self.current_date()
        years = vacations.fiscal_years_for_date(current_date)
        return [(y,y) for y in years]

See here that you just have to change the default widget (an input) for a select. You fill it with valid values and it will make impossible for a user to provide an invalid year. Note that values come from the domain layer. The domain service `vacations.fiscal_years_for_date` (here a simple function) is all you need. You won’t have to change anything but the domain rules if this changes. The logic could be as complex as you need. This is a trivial example, but you get the point.

This little piece of code is the domain adapter. An adapter doesn’t need to be a grand and costly development. Sometimes a function will do. Here we’re using a class that extends part of the web framework we’re using. And its cheap. The key point, though, is that the domain model remains isolated in its own package. And this django form (and the view and the template we’ve seen above) are just only a tiny HTTP adapter for it. And we added this rich domain behaviour without having to touch the plumbing, as we’ve promised. The view and the template stay the same!

Django Forms as Domain Adapters
Django Forms as Domain Adapters

I Want It All

We’ve made our application more robust by making the forms restrict user input. But another use case is validating that the input fulfills all the business rules.

We can do that with django forms by hooking ourselves into the clean method of the form. Say we’ve only allow asking for future vacations. We can only validate that after the user submitted valid data.

Again, we put the logic in its corresponding module in the domain

def date_should_be_greater_than_request_date(date, request_date):
    return date > request_date

and its accompanying tests, of course:

class VacationsRequestDate(TestCase):

    daydelta = datetime.date(2018, 1, 2) - datetime.date(2018,1,1)

    def test_date_should_be_greater_than_request_date(self):
        date = random_date()
        request_date = date - self.daydelta
        self.assertTrue(vacations.date_should_be_greater_than_request_date(date, request_date))

Now django helps us again. We can override the form’s clean method. There we can validate the extra business rules with already valid data, leveraging the data validation and conversion to the django form default implementation.

def clean(self):
    cleaned_data = super().clean()
    date = cleaned_data.get('date')
    if date is not None:
        request_date = self.current_date()
    if not vacations.date_should_be_greater_than_request_date(date, request_date):
        raise ValidationError(_('You can not ask vacations for days in the past'), code='invalid')

Following django, we raise a ValidationError exception to let the forms framework know there’s some invalid data. This way, django will handle it natively and will do the error reporting itself. If we’ve already customized the appearance of the form errors, we don’t have to do it again. And for the user this will be nice and clean, because the errors will be reported uniformly all over the application.

Django Forms as Domain Adapters
Django Forms as Domain Adapters

Was It All Worth It?

Simple as it might seem, the value of putting these rules into the domain is that

  • controller (view) code now doesn’t have business logic, only HTTP handling (which is its only responsability)
  • the business rules are on the domain (which is its only responsability)
  • domain logic can be tested with almost 100% coverage
  • domain logic and tests are easy to change because they are not coupled to application logic in any place
  • we leverage the plumbing to frameworks and get the benefits they provide without losing the architectural benefits of DDD
Django Forms as Domain Adapters
Django Forms as Domain Adapters

Using Django Forms as Domain Adapters—The Show Must Go On

We’ve provided a simple example to show you how to use django forms as domain adapters. For us, at the beginning it was a concern to use a framework with a huge set of facilities, because we were not sure if it will stay out of our way. Our experience so far has been very positive. We’ve realized that DDD works for us and that with the right tooling you no longer have any excuse for not bending the code to your own will.

The architecture is clean and consistent and once you realize that django forms are no other thing that domain adapters, you could even made this explicit by putting them into an `adapters` package. Django wouldn’t care but you probably will. And surely you future self, six months in the future.

Remember, you’re the boss and django and DDD are just tools. But how great they are!

For the shake of completeness, here it is the full adapter

import datetime

import django.forms.widgets as native
import capside.calendar.widgets as widgets

from capside.calendar import models
from capside.calendar import domain
from capside.infrastructure.errors import DomainException

class RequestVacationsForm(forms.Form):
    start_date = forms.DateField(widget=widgets.DateInput())
    end_date = forms.DateField(widget=widgets.DateInput())
    type = forms.ChoiceField(choices=domain.absence_request.ABSENCE_TYPES, widget=widgets.Select())
    fiscal_year = forms.IntegerField(widget=widgets.Select())

    def __init__(self, *args, **kwargs):
        self.user = kwargs.pop('user', None)
        super().__init__(*args, **kwargs)
        self.fields['fiscal_year'].widget.choices = self.fiscal_years_choices()

    def current_date(self):
        return datetime.date.today()

    def fiscal_years_choices(self):
        years = domain.absence_request.fiscal_years_for_date(self.current_date())
        return [(y,y) for y in years]

    def absence_request(self):
        cleaned_data = super().clean()
        return domain.absence_request.AbsenceRequest(
            user = self.user,
            start_date = cleaned_data.get('start_date'),
            end_date = cleaned_data.get('end_date'),
            type = cleaned_data.get('type'),
            fiscal_year = cleaned_data.get('fiscal_year'),
            request_date = self.current_date(),
        )

    def clean(self):
        cleaned_data = super().clean()
        absence_request = self.absence_request()
        try:
            absence_request.validate()
        except DomainException as e:
            raise ValidationError(e, code='invalid')