Kamu sedang membangun fitur registrasi. Tiap kali ada user baru, aplikasi harus membuat profil, mengirim email sambutan, dan menulis log audit. Kalau semuanya kamu tumpuk di view registrasi, view itu jadi gendut — dan user yang lahir dari admin panel atau manage.py createsuperuser tidak akan pernah memicu logika itu. Django Signals memecah masalah ini: kode yang peduli pada suatu kejadian cukup mendengarkan sinyalnya, tanpa perlu tahu siapa yang mengirim.
Apa Itu Django Signals?
Signal adalah pemberitahuan yang dipancarkan Django setiap kali sesuatu terjadi di ORM atau di framework. Model disimpan, model dihapus, request masuk — semuanya memancarkan sinyal. Kamu menulis receiver (fungsi yang mendengarkan) lalu menyambungkannya ke sinyal tertentu. Polanya mirip observer pattern: sender tidak peduli siapa receivernya, dan receiver tidak peduli dari mana sinyal itu datang. Django membawa sejumlah sinyal bawaan — post_save, pre_save, pre_delete, post_delete, m2m_changed, pre_init, post_init, request_started, sampai request_finished.
Setup Proyek
Kita pakai dua app: accounts untuk contoh profil, dan orders untuk contoh custom signal nanti. Buat proyeknya dulu:
python -m venv venv && source venv/bin/activate
pip install django
django-admin startproject config .
python manage.py startapp accounts
python manage.py startapp orders
Daftarkan kedua app di INSTALLED_APPS, lalu buat model Profile yang menempel ke User lewat OneToOneField. Kalau relasi model masih terasa asing, mampir dulu ke panduan Models & Migrations.
Profil Otomatis dengan post_save
post_save dipancarkan tepat setelah save() dipanggil — tidak peduli dari mana: view, admin, shell, atau sinyal lain. Argumen created bernilai True hanya saat objek baru dibuat pertama kali. Itu sinyal yang pas untuk membuat data pendamping seperti profil.
# accounts/models.py
from django.conf import settings
from django.db import models
class Profile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
def __str__(self):
return self.user.username
# accounts/signals.py
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Profile
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def buat_profil(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
# accounts/apps.py
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
def ready(self):
from . import signals # noqa: F401
Receiver harus terdaftar sebelum Django selesai loading, dan tempat yang tepat untuk itu adalah method ready() di AppConfig. Kalau app-mu cuma punya satu AppConfig, tulis "accounts" di INSTALLED_APPS dan Django otomatis memanggil ready(). Ada lebih dari satu AppConfig? Tulis path lengkapnya: "accounts.apps.AccountsConfig".
Kalau kamu memilih tidak memakai dekorator @receiver, sambungkan manual dengan post_save.connect(buat_profil, sender=User, dispatch_uid="buat_profil"). Selalu beri dispatch_uid unik saat menghubungkan manual, supaya receiver tidak terdaftar dua kali ketika ready() terpanggil lebih dari sekali — misalnya saat dev server melakukan autoreload.
Menghindari Loop Rekursif
Bug paling umum saat memakai post_save adalah memanggil save() pada model yang sama di dalam receiver-nya sendiri. Setiap save() memancarkan post_save lagi, dan begitu seterusnya. Contoh klasik: mengisi slug otomatis dari judul.
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.text import slugify
from .models import Article
@receiver(post_save, sender=Article)
def isi_slug_otomatis(sender, instance, created, **kwargs):
if not instance.slug:
instance.slug = slugify(instance.title)[:50]
instance.save(update_fields=["slug"])
Kenapa ini aman? save() kedua memancarkan post_save lagi, tapi sekarang instance.slug sudah terisi sehingga guard if not instance.slug menghentikan rekursi. update_fields=["slug"] juga membatasi query ke satu kolom saja, tidak menimpa field lain.
Jangan memanggil instance.save() tanpa guard di dalam post_save untuk model yang sama. Selain loop tak berujung, kamu bisa memicu ratusan query yang tidak perlu. Kalau memang harus mengubah field, periksa dulu apakah perubahannya benar-benar dibutuhkan, lalu pakai update_fields.
Membuat Custom Signal
Signal bawaan menutupi event di ORM. Untuk kejadian domain-mu sendiri — pesanan dibayar, stok habis, akun di-suspend — definisikan sinyal sendiri. Deklarasikan sebagai objek Signal di satu modul supaya mudah diimpor.
# orders/signals.py
import django.dispatch
pesanan_dibayar = django.dispatch.Signal()
Kirim sinyalnya dari tempat yang kamu anggap sebagai sumber kebenaran — biasanya view atau service layer, tepat setelah status berubah di database:
# orders/views.py
from orders.models import Order
from orders.signals import pesanan_dibayar
def konfirmasi_pembayaran(request, order_id):
order = Order.objects.get(id=order_id)
order.status = "paid"
order.save()
pesanan_dibayar.send(sender=Order, order=order)
# orders/handlers.py
from django.dispatch import receiver
from .signals import pesanan_dibayar
@receiver(pesanan_dibayar)
def kirim_invoice(sender, order, **kwargs):
# kirim email + generate PDF invoice di sini
print(f"Invoice untuk pesanan #{order.id} terkirim")
Konvensinya, sender diisi class model yang relevan — di sini Order, bukan string. Receiver bisa menyaring sender dengan @receiver(pesanan_dibayar, sender=Order) kalau kamu ingin beberapa receiver menangani sender yang berbeda-beda.
Offload Pekerjaan Berat ke Celery
Receiver berjalan sinkron, di thread yang sama dengan pemanggilnya. Kirim email langsung di dalam receiver dan halaman jadi ikut lambat kalau SMTP lelet. Simpan logika berat ke task queue, lalu tinggal panggil .delay() dari receiver. Pola ini cocok banget dengan Celery — cara setup Celery bisa kamu baca di sini.
Menguji Signal
Signal ikut terpanggil di test karena ready() juga jalan saat test runner memuat app. Tesnya sederhana: buat user, cek profil muncul. Kalau belum terbiasa menulis test Django, mulai dari panduan pytest ini.
# accounts/tests.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from .models import Profile
User = get_user_model()
class ProfileSignalTest(TestCase):
def test_profil_dibuat_otomatis(self):
user = User.objects.create_user(username="budi", password="rahasia123")
self.assertTrue(Profile.objects.filter(user=user).exists())
Kapan Jangan Pakai Signal
Signal nyaman tapi gampang disalahgunakan. Kalau suatu logika hanya dipanggil dari satu tempat — satu view, satu form — taruh saja di sana secara eksplisit. Signal menyembunyikan alur kontrol; orang yang baru baca kode akan bingung kenapa tiba-tiba ada profil baru padahal view-nya cuma memanggil create_user(). Pakai signal untuk efek lintas-aplikasi yang harus selalu terjadi (audit log, cache invalidation, profil default), bukan sebagai pengganti pemanggilan fungsi biasa.
Mulai dari satu kasus nyata di proyekmu: temukan satu hal yang sekarang kamu duplikasi di beberapa tempat, pindahkan ke receiver post_save atau custom signal, dan daftarkan lewat ready(). Setelah itu buka shell dan jalankan User.objects.create_user(...) — profilnya harus muncul sendiri tanpa kamu sentuh view sama sekali.