The Problem¶
Why does airlock exist? What problem does it solve?¶
Putting side effects deep in the call stack is common but dangerous:
class Order:
def process(self):
self.status = "processed"
notify_warehouse(self.id)
send_confirmation_email(self.id)
It's also tempting to centralize "conditional side effect dispatch" in a deep method. Also dangerous!
class Order:
def update_status(self, status):
notify_warehouse(self.id)
if status == "paid":
send_confirmation_email(self.id)
elif status == "shipped":
update_tracking_system(self.id)
send_update_email(self.id)
But why are they dangerous?
- You can't opt out. Every scripted creation, fixture load, and migration that calls the method fires the tasks.
- It's invisible at the call site.
order.mark_as_paid()looks innocent. You have to know to trace its call stack for side effects. - Testing is miserable. Mock at the task level (fragile), run a real broker (slow), or
CELERY_ALWAYS_EAGER=True(hides async bugs). - Bulk operations explode. A loop calling
save()on 10,000 orders enqueues 10,000 tasks. - Re-entrancy bites.
User.save()callsenrich_from_api.delay(user.id). That task fetches data, setsuser.ageanduser.income, then callsuser.save()... which enqueuesenrich_from_apiagain. Now you're adding flags like_skip_enrich=Trueand threading them through everywhere. (Or you're diffing againstModel.objects.get(pk=self.pk)in everysave()and usingsave(changed_fields=[])as a task dispatcher. Now you have three problems.)
The problem isn't where the intent is expressed. It's that the effects are silent, and escape immediately.
Stuff it all in an airlock¶
With airlock, you express an intent to perform a side effect, but the side effects don't escape until someone lets them out:
import airlock
class Order:
def process(self):
self.status = "processed"
airlock.enqueue(notify_warehouse, self.id) # Buffered for later
airlock.enqueue(send_confirmation_email, self.id) # Buffered for later
Now these methods are a legitimate and safe place to express domain intent:
- Colocation. The model knows when it needs side effects. You may want that knowledge to belong here.
- DRY. Every code path that saves an Order expresses the side effects. You can't forget.
- Control. The scope decides what escapes, not the call site.
- Visibility. You can inspect the buffer before it flushes... run a model method and compare before-and-after... great for tests!
- Control again. Define your own nested scopes for surgically stacked policies, or even define multiple execution boundaries.
Side effects can be defined close to the source, and still escape in one place.
What this unlocks¶
Without airlock, "enqueue all side effects at the edge" is an important constraint for maintaining predictable timing, auditability, and control. Side effects deep in the call stack are dangerous, so you're forced to pull them out.
With airlock, both patterns are safe:
- Edge-only: All enqueues in views/handlers. Explicit, visible at the boundary.
- Colocated: Enqueues near domain logic (
save(), signals, service methods). DRY, encapsulated.
Choose based on your preferences, not out of necessity.
Do I really need this...?¶
- See Alternatives