Buttondown, a newsletter platform, has been running Django in production for eight years. In a post on the company's blog, founder Justin Duke explains the specific Django features that have provided the most long-term leverage. He also details what they deliberately avoid. The post is a practical look at how a mature Django codebase stays maintainable.

Duke's core argument: Django's structure becomes invisible over time. He says, "I do not look at Buttondown and see a Django app; I see a well-structured codebase with many things that have been solved by smarter people than myself." That invisibility is the goal.

Middleware: Simple, Powerful, Underused

Django's middleware abstraction is a simple function that acts on the request/response lifecycle. Duke calls it "incredibly simple, and thereby incredibly powerful." They use middleware for subdomain routing, UTM tracking, setting Content-Security-Policy headers, recording pageviews, and binding request context to logs.

The example he shares stamps every response with the deployed git SHA:

# app/emails/middlewares/build_version.py
class Middleware:
    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
        self.get_response = get_response

    def __call__(self, request: HttpRequest) -> HttpResponse:
        response = self.get_response(request)
        if settings.HEROKU_SLUG_COMMIT and not flag_is_active(CIRCUIT_BREAKER_FLAG):
            response[BUILD_VERSION_HEADER] = settings.HEROKU_SLUG_COMMIT
        return response

Duke suggests that the median Django developer should take more advantage of middleware.

Base Models: Opt-in Power

Every model in Buttondown inherits from a BaseModel. It provides a UUID primary key, a creation_date, type-prefixed IDs, implicit change tracking, durable provenance, per-field validation hooks, and an opt-in soft-delete manager. All of it is additive and opt-in.

Here's a trimmed version:

# app/utils/models.py
class BaseModel(models.Model):
    creation_date = models.DateTimeField(auto_now_add=True)
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    objects = TypeIDAwareManager()

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        # For any tracked field that actually changed, fire its
        # handle__change hook and persist a transition row.
        ...

    class Meta:
        abstract = True
        ordering = ("-creation_date",)

The key is that adding new behavior doesn't require a migration or refactor. For example, adding change tracking to a field is a one-method definition:

class Email(BaseModel):
    def handle_body_change(self, **kwargs) -> None:
        AsynchronousAction.enqueue(sync_snippet_references, [str(self.id)])

And adding provenance tracking is a one-line dict entry:

@classmethod
def tracked_field_to_transition_class(cls) -> dict[str, type[BaseTransition]]:
    return {"status": EmailStatusTransition}

This approach makes it easy to add cross-cutting concerns without touching existing code.

Actions: One Verb per Module

Instead of letting model classes accrete dozens of methods, Buttondown puts each behavior in its own file under an actions/ folder. Each module exposes a call() function. Here's the whole "ban a subscriber" action:

# app/emails/models/subscriber/actions/ban.py
from emails.models.subscriber.actions import end_premium_subscription
from emails.models.subscriber.model import Subscriber

def call(subscriber: Subscriber) -> None:
    if subscriber.subscriber_type == Subscriber.Type.PREMIUM.value:
        end_premium_subscription.call(str(subscriber.id))
    subscriber.subscriber_type = Subscriber.Type.REMOVED.value
    subscriber.save(update_fields=["subscriber_type", "modification_date"])

This keeps models lean and makes behavior discoverable.

Views: Boring by Design

Buttondown's views are strictly function-based, live in their own file, and are named view. No class-based views. Duke explains: "Why be so boring and strict? Largely due to the pain of context switching." He argues that maintainable view code is more about avoiding failure than finding success.

Here's a complete view example:

# app/emails/views/record_lifecycle_email_open.py
def view(request: HttpRequest, compressed_id: str) -> HttpResponse:
    try:
        account_id, email_type = _decode_open_payload(compressed_id)
    except (UnicodeDecodeError, Base64Error, ValueError):
        return HttpResponse(TRANSPARENT_GIF, content_type="image/gif")

    with transaction.atomic():
        LifecycleEmailEvent.objects.create(
            account_id=account_id,
            email_type=email_type,
            event_type=LifecycleEmailEvent.EventType.OPENED,
            timestamp=timezone.now(),
            metadata=_build_metadata(request),
        )
    return HttpResponse(TRANSPARENT_GIF, content_type="image/gif")

Testing: Hand-Rolled Fixtures

Buttondown uses pytest and pytest-django, but deliberately avoids factory libraries like Factory Boy. They hand-roll fixtures for performance. A test looks like this:

# app/emails/views/record_lifecycle_email_open--test.py
def test_records_open_event(account):
    encoded = encode_open_payload(str(account.pk), "unconfirmed")
    request = RequestFactory().get(f"/lo/{encoded}/")
    response = view(request, encoded)
    assert response.status_code == 200
    event = LifecycleEmailEvent.objects.get(account=account)
    assert event.event_type == LifecycleEmailEvent.EventType.OPENED

What They Leave Out

Buttondown avoids several common Django features:

  • Signals: They have exactly one signal (a link into django-allauth). Internal signals are an anti-pattern that hurts reasoning and performance.
  • Class-based views: They cause context-switching overhead. Function-based views are more uniform.
  • Apps: They don't use Django apps for modularity. Cross-app migrations are painful, and folder organization works fine. Exceptions: core API infrastructure and anything they might open-source.
  • Checks: They prefer weird tests over checks. It's simpler and one fewer moving part.
  • Forms and front-end: No Django forms. They use a hydration pattern: Django renders a thin shell, seeds it with JSON via json_script, and Vue hydrates from that payload. A single view backs nearly every page.

Why Django?

Duke chose Django because it's what he knew in 2018. He was working with Django and Vue at his day job. He says, "I have not for one second regretted using Django."

The post is a masterclass in pragmatic Django. It shows that a few deliberate, opinionated choices can keep a codebase maintainable for years.