blog

How I organize Buttondown's Django applications

I nerd out about structuring a medium-sized Django project

How I organize Buttondown's Django applications
Heads up

This is a pretty technical post with absolutely no new information about Buttondown's features! If you're not interested in Django (or don't know what Django is), feel free to skip this one.

Wait, what's Buttondown?

I run Buttondown, a newsletter tool. It's a pretty nice tool, in my opinion! Please check it out if you get a chance.

This application is around 45,000 lines of Django code [^2], with commits dating back to 2016. It's grown and mutated a lot over the past six years.

I've been meaning to write more about how my approach to structuring and developing Buttondown has changed over the years, and I think a good jumping off point would be the biggest conceptual tool Django offers to organize your code: apps.

Wait, what are apps?

The official Django docs define applications as:

A Django application is a Python package that is specifically intended for use in a Django project. An application may use common Django conventions, such as having models, tests, urls, and views submodules.

Okay, that's not particularly useful. Two Scoops of Django is more useful:

Django apps are small libraries designed to represent a single aspect of a project. A Django project is made up of many Django apps. Some of those apps are internal to the project and will never be reused; others are third-party Django packages.

Or to go further still, to quote James Bennett:

The art of creating and maintaining a good Django app is that it should follow the truncated Unix philosophy according to Douglas McIlroy: ‘Write programs that do one thing and do it well.”’

Apps are essentially a DSL around modules: they provide a level of namespacing and structure around logically disjoint pieces of functionality & business logic.

My general philosophy with apps design

As far as I can tell, there are two main competing visions for how to use apps in Django:

  1. Apps should be self-contained groups of primitives (models & business logic) that are logically independent, as quoted above.
  2. Apps are a dumb idea for the vast majority of codebases and introduce a bunch of unnecessary friction, to be avoided at all costs. [^1]

I think I land somewhere in the middle, but leaning towards the former camp. My heuristic is roughly: is this code something that I would want to break out into its own codebase or deploy target at some point?

Here's a top level overview of the apps Buttondown has with some high-level metadata; keep scrolling for more in-depth information.

app

lines of code

number of models

year introduced

api

2000

1

2018

checker

1300

4

2021

email_address_validation

1500

3

2018

emails

30000

37

2016

events

1400

1

2017

flags

500

1

2021

marketing

500

2

2016

markdown_rendering

1000

5

2019

monetization

3500

11

2020

api

Buttondown has an external-facing API with the long-term goal of exposing all important parts of the app's functionality for programmatic access. (The somewhat silly Platonic ideal I strive towards: someone should be able to build out their own Buttondown client just with the API surfaces I provide.)

As such, I carve out that API as its own app. This app doesn't own many models in of itself — except a fairly generic APIRequest model which tracks incoming requests and outgoing responses — but acts as a superstructure for various primitives owned by emails, owns the schema validation & DRF views, that sort of thing.

If I had a magic wand and/or infinite time, this app would probably be shaped a little differently: more like a series of middlewares and decorators that, when applied to other apps, could expose those apps to the external API.

This is a nice theoretical exercise, but the ROI on such work feels very low. I know apps are mostly about model separation, but I think "separation of concerns" (mushy of a phrase as that might be ) is particularly valid, and has served me well here.

checkers

One of the internal frameworks I really loved at Stripe was "checker", which was a very pleasant DSL for declaring programmatic scheduled invariant checks in your code. This is my shamelessly reappropriated version of that framework, and its proven so invaluable I'm surprised that it's not more ubiquitous.

The core of this app is a decorator, @register_checker, which takes a function that returns CheckerFailure objects and does a bunch of metaprogramming to email (or page) me whenever a list returns something.

Sometimes I use this for administrative tasks, like manually auditing new accounts who connect Stripe accounts with pre-existing customers & charges:

@register_checker
def no_stripe_accounts_need_auditing() -> Iterable[CheckerFailure]:
    for newsletter in Newsletter.objects.filter(
        paid_subscriptions_status=Newsletter.PaidSubscriptionsStatus.NEEDS_AUDITING
    ):
        if newsletter.stripe_account:
            yield CheckerFailure(
                text=f"Stripe account {newsletter.stripe_account.account_id} needs auditing",
                subtext=f"""
                    Newsletter: {newsletter}
                    Link to Stripe: https://dashboard.stripe.com/connect/accounts/{newsletter.stripe_account.account_id}
                    Admin URL: https://admin.buttondown.com/admin/emails/newsletter/{newsletter.id}/change/
                """,
                data={"newsletter_id": str(newsletter.id)},
            )

Other times, I use it for checking to ensure that no emails are in a problematic state space that necessitate re-driving or SMTP shenanigans:

@register_checker(
    severity=Checker.Severity.HIGH, cadence=Checker.Cadence.EVERY_TEN_MINUTES
)
def no_emails_stuck_in_flight() -> Iterable[CheckerFailure]:
    for email in fetch_relevant_emails():
        text = f"Email {email.id} (from {email.newsletter.username}) is stuck in flight"

        expected_receipts = calculate_expected_receipts(email)
        if not expected_receipts:
            continue

        actual_receipts = EmailDeliveryReceipt.objects.filter(email=email)
        # 'high' is the queue used for asynchronously_send_email_to_recipients.
        queue = django_rq.get_queue("high")
        current_backlog_size = queue.count

        subtext = (
            f"Expected {expected_receipts.count()} receipts "
            f"but only received {actual_receipts.count()}.  "
            f"Current backlog: {current_backlog_size}"
        )
        yield CheckerFailure(
            text=text, subtext=subtext, data={"email_id": str(email.id)}
        )

(I really, really want to open-source this, and probably will at some point this year.)

email_address_validation

The email_address_validation app acts as a simple interface that takes strings and returns a ValidationResult object associated with them. That result is compiled from a number of heuristics, from regular expressions to internal data to external services like CleanTalk and Mailgun.

I'm pretty happy with this interface! I originally came to the approach with the concept of one-day deploying this as a stand-alone SaaS, and while my appetite in doing so has largely abated (feels like a lot of boilerplate for a fairly small incremental bump in revenue) it's made the rats' nest of logic well-encapsulated.

emails

emails is, as one might guess from the name, the oldest and largest app in the codebase. It contains the three most important models in Buttondown — Subscribers, Newsletters, and Emails — plus another 34 to boot. By default, most logic ends up here; it is the sun of Buttondown's little heliocentric universe, as you might have surmised from that summary table above.

I've idly mused on what splitting up emails would look like, and haven't come up with any satisfactory answers. The logic here is tightly coupled, and decoupling feels very low-ROI.

events

Buttondown generates a lot of events from third-party applications. I store data points for every attempted (and successful) email delivery in order to both surface analytics to newsletter authors and to track internal heuristics like particularly spammy authors or slow-to-respond domains. That's not even getting into opt-in functionality like click or open tracking which leads to even more events.

Plus, Buttondown connects to a swath of ESPs (Mailgun, AWS, and Postmark to name a few) which means the interface for events between providers has to be relatively consistent. Enter this app, which stands up a bunch of webhook routes and munges responses into a generic EmailEvent object that contains pointers to other models.

(Also, shout out to django-anymail, which makes this process much easier.)

A not so fun fact: I've titled this section events, but the name of the app is actually mailgun_events — Mailgun was Buttondown's first ESP, so it made sense at the time. Mailgun's now only processing around 5% of Buttondown's email traffic. Let this be a lesson to you all!

flags

For a decently long amount of time, I used django-waffle to manage flags and switches for Buttondown. I try to avoid phased rollouts in the "1% of traffic, then 10% of traffic, then 25% of traffic" vein except for very fraught situations, but I find these primitives useful in two scenarios:

  1. Pieces of functionality like "make sure this specific newsletter returns a 301 instead of a 300 because I signed a lucrative annual contract with them" which I really don't want to enshrine at the database level
  2. Circuit breakers like "turn off all access to CleanTalk because they started returning a bunch of timeouts and I don't want it gumming up the works of synchronous API requests."

I decided to end up ditching django-waffle and focus on a lighter-weight flags system to reduce my third-party dependency surface area a bit and, more importantly, to reduce the number of database queries I'd need for some particularly chokepoint-y areas like email rendering or address validation.

I'd like to open source this app at some point: it's very lightweight and conceptually simple.

marketing

If I could really wave a magic wand, I'd have this app not exist at all: conventional wisdom these days is to have your marketing site be in a different codebase entirely, and to have your core application be on app.domain.com.

Unfortunately, I was not conventionally wise in 2016, and at this point it's not so trivial for me, since buttondown.email/<username> is where most folks' archives live and I'd need to do some tricky routing shenanigans.

So what do I do instead? I serve the marketing pages in boring ol' Django, and store the code for them in a standalone app so the views and routes are at least in their own little world. There are a couple models here — namely Updates (which powers a little widget in the login page letting folks know about new features) — but data and business logic is thin-to-non-existent here.

markdown_rendering

Buttondown is a huge fan of Markdown, and has quite a bit of dedicated logic to make it play nice: I've got custom extensions for tables, footnotes, highlighting, smart embeds (for things like Instagram and YouTube, whose oembeds are not email-friendly due to their use of iframes), and much more.

This all culminates in a single method meant to be the external 'interface': render, which takes a string and a RenderingTarget (either "for the web" or "for email").

This has been a really nice encapsulation of business logic & function. While I don't see myself open-sourcing or spinning off a dedicated rendering instance or anything like that, it makes it very easy to dive into rendering esoterica and it leads to very maintainable code.

monetization

This app is a bit of a misnomer, since it sounds like it might pertain to either managing paid newsletters or managing paying users. It's actually even more simple than that: it's a series of webhooks and models meant explicitly to replicate Stripe's state space in my own database.

This app explicitly doesn't have any business logic; any operations like "mark a subscriber as churned once a StripeSubscription is updated" or "create a new subscriber when a new StripeCustomer is created" are handled within emails.

(I'm aware of the existence of dj-stripe, which seemed...a little cumbersome and confusing the two times I tried to onboard to it. Building out my own equivalent probably took more time than it was worth, but I appreciate the gradually revealed complexity and it felt like a particularly important part of the database to treat with care.)

Linus asked to hear more about why do this at all, let alone with a bespoke app. A couple reasons, in descending order of magnitude:

  1. Complexity. Buttondown cares about Stripe a great deal more than the median SaaS app. While its billing model is fairly simple, it also uses Stripe Connect to manage paid subscriptions for hundreds of other newsletters, meaning that there are a lot of subscriptions, plans, customers, and so on.
  2. Speed. Not only does Buttondown care about Stripe a great deal, Stripe is often in the critical path of customer-facing transactions. Billing flows in general can afford to be slow: it doesn’t really matter if it takes ten seconds to generate an invoice view that’s hit by some back-of-office administrators a couple times a day. However, it’s important that Buttondown can answer questions like “when did this paid subscriber pause their subscription?” or “how many new customers did this newsletter get last week?” relatively expediently, because these are core flows! And where core flows are concerned, network calls to api.stripe.com are painful, let alone having to deal with some of the pagination and eventual consistency tradeoffs Stripe has enshrined.
  3. Connect. Certain queries I want to keep track of from an operational perspective are literally impossible in Stripe at the moment, such as “give me all disputes across all connected accounts in the past 24 hours.”

In terms of how I actually model this within Postgres: I try to respect foreign key relationships, and plop the rest of the data in JSONB. For example, here’s the StripeSubscription model:

class StripeSubscription(BaseStripeModel):
    # Core information.
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    data = models.JSONField(default=dict)

    # Relevant dates.
    creation_date = models.DateTimeField(auto_now_add=True)
    modification_date = models.DateTimeField(auto_now=True)

    # Stripe-side primary key.
    subscription_id = models.CharField(max_length=100, unique=True)

    # Foreign keys.
    customer = models.ForeignKey(
        "StripeCustomer",
        on_delete=models.CASCADE,
        related_name="subscriptions",
    )
    account = models.ForeignKey(
        "StripeAccount",
        on_delete=models.CASCADE,
        related_name="subscriptions",
    )
    plan = models.ForeignKey(
        "StripePlan",
        on_delete=models.CASCADE,
        related_name="subscriptions",
    )

Conclusion

That's the full list! I subscribe to a pattern that I suspect is fairly common amongst codebases of Buttondown's size: one overstuffed, gross "primary" application and a bunch of orbiting, comparatively better-designed ones.

I like using apps: they make my brain happy in the same way cleaning up test code makes me happy (which is to say, sometimes the happiness is worth the lack of obvious business value.) That being said, I'd be warier of being over-aggressive with app usage as opposed to being over-conservative; I've lost afternoons to chasing down cross-app migration issues whereas besides some very long models lists I can't point to any specific footguns I've stumbled upon from having a Very Big Django App.

Please let me know if you have any questions — I'm all ears!

[^1]: As someone who's lost five hours of their life to shenanigans with cross-app migration squashing, I can certainly sympathize with some of the histrionics of this world-view. [^2]: Well, and a bunch of front-end code, but that's out of scope for this post. Also, my front-end code is much more poorly organized than my back-end code.

Published on

June 8, 2022

Filed under

Written by

Justin Duke

Justin Duke is a software engineer, lover of words, and the creator of Buttondown.

No credit card required. Only pay for what you use. Cancel anytime.