Compass Navigation Streamline Icon: https://streamlinehq.com
applied cartography

In praise of actions

When I first encountered all of the concepts that I'll describe as controllers or actions or services, I would try to adhere to them with the logic of consistency being its own virtue, but never quite understand why and how they came to be so prevalent. And indeed, it seems like with the tide shifting away from OOP and J2EE-style programming and onto more dynamic programming, they have become less in vogue.

Now that I've gotten to watch a larger codebase mature in both good ways and bad, I've grown to appreciate what one particular convention — actions — solves in relation to what came before them, which, crucially, was nothing. Business logic would just kind of end up wherever it seemed vaguely reasonable to put it, often in a services or utils directory, with not a lot of consistency or organizational standards.

A basic example

The Subscriber model, which is exactly what you think it is, has a lot of actions registered to it:

emails/models/subscriber/actions/
├── activate.py
├── apply_tags.py
├── ban.py
├── block.py
├── change_email_address.py
├── delete.py
├── end_premium_subscription.py
├── mark_as_not_spammy.py
├── mark_as_undeliverable.py
├── modify_stripe_subscription.py
├── resubscribe.py
├── send_email.py
├── send_magic_link.py
├── track_click.py
├── track_open.py
├── unban.py
├── unsubscribe.py
└── ...

Let's zoom in on unsubscribe:

# emails/models/subscriber/actions/unsubscribe.py

class Source(str, Enum):
    USER = "user"
    SUBSCRIBER = "subscriber"
    API = "api"
    ADMIN = "admin"
    AUTOMATION = "automation"


def call(
    subscriber: Subscriber,
    unsubscription_source: Source,
    email_id: UUID | str | None = None,
) -> None:
    if (
        subscriber.subscriber_type
        not in fetch_subscribers.EMAIL_TYPE_TO_SUBSCRIBER_TYPES[Email.Type.PUBLIC]
    ):
        return

    if subscriber.subscriber_type == Subscriber.Type.PREMIUM.value:
        end_premium_subscription.call(subscriber)

    create_external_event.call.delay(
        str(subscriber.newsletter.id),
        ExternalEvent.Type.SUBSCRIBER_UNSUBSCRIBED,
        {
            "subscriber": str(subscriber.id),
            "unsubscription_source": unsubscription_source,
            "email": str(email_id) if email_id else None,
        },
    )
    subscriber.subscriber_type = Subscriber.Type.UNSUBSCRIBED.value
    subscriber.unsubscription_date = timezone.now()
    if email_id:
        subscriber.email_which_prompted_unsubscription_id = normalize_id(
            str(email_id)
        )
    subscriber.unsubscription_source = unsubscription_source
    subscriber.save()

You might be tempted to think this is simple, but it has to handle dealing with linked Stripe and/or Memberful subscriptions (via end_premium_subscription), has to deal with recording and emitting external events to trigger things like automations, has to deal with provenance and metadata (via the Source enum) so authors can know when and why a reader unsubscribed, and so on. This is still not a tremendously complicated file — the unamended version is 134 lines as of this writing — but it's doing real work.

Many callers

Additionally, lots of things can unsubscribe a user. Every subscriber can manually unsubscribe themselves, for which there are multiple options, such as the one-click unsubscribe POST method as mandated by Gmail et al.:

# emails/views/subscriber_facing/unsubscribe.py

def view(request: HttpRequest, subscriber_id: str, ...) -> HttpResponse:
    subscriber = Subscriber.objects.get(id=subscriber_id)

    # This should only be used for `List-Unsubscribe-Post`.
    if request.method == "POST":
        unsubscribe.call(subscriber, unsubscribe.Source.SUBSCRIBER, email_id)
        return HttpResponseNoop()

You can unsubscribe people through bulk actions:

# emails/models/bulk_action/actions/process.py

TYPE_TO_CALLABLE = {
    # ...
    BulkAction.Type.UNSUBSCRIBE_SUBSCRIBERS: wrap_action(
        unsubscribe.batch_call, unsubscribe.Source.ADMIN
    ),
    # ...
}

Through automations:

# emails/models/automation/actions/process.py

elif action_type == Automation.ActionType.UNSUBSCRIBE_SUBSCRIBER.value:
    unsubscribe.call(
        subscriber,
        unsubscribe.Source.AUTOMATION,
    )

Through the internal admin interface:

# emails/admin/subscriber.py

def unsubscribe(self, request: HttpRequest, queryset: QuerySet) -> None:
    for subscriber in queryset:
        unsubscribe.call(subscriber, unsubscribe.Source.ADMIN)

And through the API itself:

# api/views/subscribers/routes.py

if value == SubscriberModel.Type.UNSUBSCRIBED.value:
    unsubscribe.call(
        instance,
        unsubscribe.Source.API
        if isinstance(request.auth, Newsletter)
        else unsubscribe.Source.SUBSCRIBER,
        payload.email_which_prompted_unsubscription_id,
    )

Every single one of these callsites just invokes unsubscribe.call. They don't know or care about the details of Stripe cancellation, external event emission, or confirmation emails. They just pass in a subscriber, a source, and optionally an email ID.

Batch vs. one-off

Not only can unsubscription happen in a bunch of places, but it can happen in a bunch of different contexts. The two biggest axes of context are batch vs. one-off and inline vs. out-of-band.

Any meaningful application gets to run into performance issues when it tries to do a non-trivial operation 50,000 times in a row, which is why all actions must have a batch_call variant in addition to call:

# emails/models/subscriber/actions/unsubscribe.py

def batch_call(
    subscribers: QuerySet, source: Source, email_id: UUID | str | None = None
) -> None:
    relevant_subscribers = subscribers.filter(
        subscriber_type__in=fetch_subscribers.EMAIL_TYPE_TO_SUBSCRIBER_TYPES[
            Email.Type.PUBLIC
        ]
        + [Subscriber.Type.UNACTIVATED.value]
    )

    for subscriber in relevant_subscribers.filter(
        subscriber_type=Subscriber.Type.PREMIUM.value
    ):
        end_premium_subscription.call(subscriber)

    create_external_event.batch_call.delay(
        str(arbitrary_first_subscriber.newsletter.id),
        ExternalEvent.Type.SUBSCRIBER_UNSUBSCRIBED,
        [
            {
                "subscriber": str(subscriber.id),
                "unsubscription_source": source,
                "email": str(email_id) if email_id else None,
            }
            for subscriber in relevant_subscribers
        ],
    )
    relevant_subscribers.update(
        subscriber_type=Subscriber.Type.UNSUBSCRIBED.value,
        unsubscription_date=timezone.now(),
        modification_date=timezone.now(),
    )

batch_call does the same logical work as call, but with QuerySet-level operations (update instead of save, batched event emission instead of one-at-a-time) so that unsubscribing 50,000 people doesn't mean 50,000 individual database writes.

The ID interface

For inline actions, it's most ergonomic to pass around a given model. This lets you apply three actions in sequence without having to decompose it into an ID and then refetch it each time, saving overhead.

But for async work, passing the model is a mistake. It's a really bad idea to serialize an entire Django model into Redis — suddenly you have to deal with size issues for larger models, stale state, validation concerns, and so on. This is why we mandate that the interface for asynchronous actions is always an ID rather than the model itself:

# emails/models/subscriber/actions/delete.py

@job(Queue.five_minutes.value)
def call(subscriber_id: str, hard_delete: bool = False) -> None:
    batch_call.delay(Subscriber.objects.filter(id=subscriber_id), hard_delete)


@job(Queue.five_minutes.value)
def batch_call(subscribers: QuerySet, hard_delete: bool = False) -> None:
    for subscriber in subscribers.filter(
        subscriber_type__in=end_premium_subscription.RELEVANT_SUBSCRIBER_TYPES
    ):
        end_premium_subscription.call(subscriber)
    # ...

delete.call takes a subscriber_id string, not a Subscriber object. The model gets fetched fresh from the database inside the action. This means the thing that gets serialized into Redis for async execution is just a UUID — small, stable, and free of validation headaches.

The pushback

The most common pushback against this style of organization is that it feels awkward when you don't actually have an action based around a specific model. This often reminds me of the pushback against REST, and the rebuttal I would offer is the same: there always is a model, you just haven't quite discovered it yet. (See Adam Wathan's talk on this.)

Why this works

The actions/ convention isn't a framework or a DSL. It's just a directory with modules that expose call and batch_call functions. But that thin layer of structure means:

  1. Every piece of business logic has an obvious home (model/actions/verb.py)
  2. Every callsite reads the same way (unsubscribe.call(subscriber, source))
  3. Batch and single operations share logic but can optimize independently
  4. Async boundaries are clean (IDs in, models fetched inside)

At some point, it'll be an interesting project to try and DSL this or turn it into something that is aware of permissions and logging and other fancy things. But for now, a tiny little bit of indirection yields a lot of structure and leverage.

You are literally just talking about functions

That is my exact point. The virtue in this system comes not from magic, but from mise en place. It's easy to figure out where every bit of business logic lives, how to invoke it, and where to put new ones. You get a huge amount of the benefits of rigidity purely by declaring every function call and giving it its own file.


About the Author

I'm Justin Duke — a software engineer, writer, and founder. I currently work as the CEO of Buttondown, the best way to start and grow your newsletter, and as a partner at Third South Capital.

Colophon

You can view a markdown version of this post here.