About Finite-State Machines in Django

Finite-state machines (FSMs) are awesome. I was first introduced to the concept through the XState JS package. This package is, obviously, geared towards front end work, but the concepts are the same.

Now, I'm no expert in the theory behind FSMs, so there's a good chance I'm getting some of the subtleties wrong here, but the basic idea is this: FSMs are a way to mathematically describe a system that 1) can only be in a single state at any given time, and 2) has a limited set of valid transitions between states.

Let's look at a simple example in Django, using django-fsm's FSMField. Let's say we're building a blog, and we have an Article model that can be in one of three states: draft, unpublished, and published.

from django_fsm import FSMField

class Article(models.Model):
    DRAFT = "draft"
    UNPUBLISHED = "unpublished"
    PUBLISHED = "published"    
    STATE_CHOICES = (
        (DRAFT, "Draft"),
        (UNPUBLISHED, "Unpublished"),
        (PUBLISHED, "Published"),
    )

    state = FSMField(choices=STATE_CHOICES, default=DRAFT)

Nothing out of the ordinary yet. But now let's define our transitions.

from django_fsm import FMSField, transition

class Article(models.Model):
    state = FSMField(choices=STATE_CHOICES, default=DRAFT)

    @transition(field=state, source=DRAFT, target=UNPUBLISHED)
    def finish_draft(self):
        pass

    @transition(field=state, source=UNPUBLISHED, target=PUBLISHED)
    def publish(self):
        pass

Now we're getting somewhere! But here's the cool bit: the pass in these methods is not pseudocode that I'm throwing in for this example. These methods will actually transition the model's state. You can also add whatever side effects you want to the methods, and the state will only change if the side effects don't raise an error, e.g.

@transition(field=state, source=DRAFT, target=UNPUBLISHED)
def finish_draft(self):
    if not validate_blog_article():
        raise ValidationError

(Note the above flow is better done with conditions, but you get the idea.)

You can take this all one step further by protecting your FSM fields:

from django_fsm import FSMField

class Article(models.Model):
    state = FSMField(choices=STATE_CHOICES, default=DRAFT, protected=True)

Now, if you try to modify an Article's state through direct assignment, you will raise an error.

FSMs are a great way to limit the set of possible states that your objects can be in. And this is a good thing. A small set of possible states means fewer edge cases to worry about (and write tests for). In general, it just becomes easier to reason about your application, what flows are possible, and what states it might be in at any given time.

A few gotchas to be aware of:

  1. A transition does not call the model's save method. You must do this manually.
  2. You cannot call refresh_from_db on a model instance with a protected FSMField
  3. FSMFields do nothing to limit QuerySet.update state changes. Developer discipline (or overwriting the update method, but I wouldn't) still required.