Batu Lab NotesPractical developer guides

Use a custom exception for an invalid state transition

By Batu · English technical notes

Use a custom exception when an invalid workflow transition needs machine-readable context. In this example, attempting queued -> published raises InvalidTransition, whose from_state and to_state attributes are respectively 'queued' and 'published'. A bare ValueError can say that something is wrong, but it does not establish a domain-level contract for callers that need to decide what happened.

The transition function owns the workflow rule. Its allowed-transition table contains only queued -> reviewing and reviewing -> published; it checks the requested target before mutating any state. On the failing branch, it constructs the exception with both values and gives it a readable message for logs. The caller catches exactly InvalidTransition, then uses attributes rather than parsing that message. This separation permits future message wording changes without breaking control flow.

Custom exception classes are regular classes, commonly derived from Exception. Keep the data they expose small and stable. This fixture demonstrates one prohibited transition only; it is not a complete state-machine framework, does not persist a workflow, and does not address concurrent transitions. For a larger model, centralize transition rules and consider carrying an operation identifier alongside the states. The assertions prove the defined exception boundary and attributes for these synthetic states.

This example uses no newer API; it runs on supported Python 3 versions.

AI assistance disclosure: this article was drafted with AI assistance and the synthetic example is intended to be run locally.

Source: Python’s errors and exceptions tutorial explains defining and raising custom exceptions.

class InvalidTransition(Exception):
    def __init__(self, from_state, to_state):
        self.from_state = from_state
        self.to_state = to_state
        super().__init__(f"cannot transition from {from_state} to {to_state}")


ALLOWED = {
    "queued": {"reviewing"},
    "reviewing": {"published"},
}


def transition(from_state, to_state):
    if to_state not in ALLOWED.get(from_state, set()):
        raise InvalidTransition(from_state, to_state)
    return to_state


try:
    transition("queued", "published")
except InvalidTransition as error:
    assert error.from_state == "queued"
    assert error.to_state == "published"
    print(f"from_state: {error.from_state}")
    print(f"to_state: {error.to_state}")
from_state: queued
to_state: published