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.
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.
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.
As far as I can tell, there are two main competing visions for how to use apps in Django:
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:
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:
api.stripe.com
are painful, let alone having to deal with some of the pagination and eventual consistency tradeoffs Stripe has enshrined.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",
)
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.