Django

Lesson 09

Transactions and side effects

Wrap related database writes in atomic transactions and run external side effects after commit.

Good Code

reviews/services.py
from django.db import transaction


def approve_review(review_id, moderator):
    with transaction.atomic():
        # Lock the row while this approval is being saved.
        review = Review.objects.select_for_update().get(pk=review_id)
        review.approve(moderator=moderator)
        review.save(update_fields=["status", "approved_by", "approved_at"])

        AuditLog.objects.create(
            actor=moderator,
            action="review.approved",
            review=review,
        )

        # Send email only after the transaction commits.
        transaction.on_commit(
            lambda: send_review_approved_email(review.pk)
        )

    return review

Bad Code

reviews/services.py
def approve_review(review_id, moderator):
    review = Review.objects.get(pk=review_id)
    review.approve(moderator=moderator)
    review.save()

    # Email can be sent before the database work fully succeeds.
    send_review_approved_email(review.pk)

    AuditLog.objects.create(
        actor=moderator,
        action="review.approved",
        review=review,
    )
    return review

Review Notes

What to review

Good Code

The good version locks the row, saves the review and audit log atomically, and sends email only after the database commit succeeds.

Bad Code

The bad version can send an email before the audit row is written. If the final write fails, the outside world has already been told the change happened.

Takeaways

  • Database changes and external notifications need an explicit ordering contract.