🧩 Building a Pluggable Architecture in Django — Introducing django-plugin-system
By Alireza Tabatabaeian
🚀 Why I built it
Every mature Django project eventually hits this problem:
“I need to switch providers — but I don’t want to rewrite my entire codebase.”
Maybe you started sending OTPs with Twilio, then later needed to add Kavenegar, or you want users to pick their notification channel (Email, SMS, Push).
Hardcoding logic for every provider leads to chaos — conditionals, repeated imports, redeploys, and no flexibility.
So I built django-plugin-system — a small, framework-native package that lets you make Django truly pluggable.
🧠 What it does
This library gives you a simple pattern to:
- Define an interface (like
AbstractOTP) - Register multiple implementations (Twilio, Kavenegar, etc.)
- Sync them automatically into the database
- Manage them easily in Django Admin
- Dynamically choose and swap plugins at runtime
🛠️ Installation
pip install django-plugin-system
Add it to your project:
INSTALLED_APPS = [
# ...
"django_plugin_system",
]
Run migrations:
python manage.py migrate
That’s all it takes 🎉
🧩 Step 1 — Define an Interface
Let’s start simple: a notification system that can send messages by Email, SMS, or Push.
# apps/notifications/interfaces.py
from abc import ABC, abstractmethod
class AbstractNotifier(ABC):
@abstractmethod
def send(self, user, message): ...
🪄 Step 2 — Register a Plugin Type
Tell Django that this interface is a plugin type.
# apps/notifications/apps.py
from django.apps import AppConfig
from django_plugin_system.register import register_plugin_type
from .interfaces import AbstractNotifier
class NotificationsConfig(AppConfig):
name = "apps.notifications"
def ready(self):
register_plugin_type({
"name": "notifier",
"manager": self.name,
"interface": AbstractNotifier,
"description": "Notification channels (Email, SMS, Push, etc.)",
})
🧩 Step 3 — Add a Plugin Item
Each plugin item is an implementation of the interface.
# apps/notifications_email/plugins.py
from django_plugin_system.register import register_plugin_item
from apps.notifications.interfaces import AbstractNotifier
class EmailNotifier(AbstractNotifier):
def send(self, user, message):
print(f"Sending email to {user.email}: {message}")
register_plugin_item({
"name": "email",
"module": "apps.notifications_email",
"type_name": "notifier",
"manager_name": "apps.notifications",
"plugin_class": EmailNotifier,
"priority": 5,
"description": "Send notification via Email",
})
You can register as many implementations as you want — SMS, Push, WhatsApp, etc.
🔄 Step 4 — Sync Registry to Database
The system auto-syncs after migrations, but you can trigger it manually anytime:
python manage.py pluginsync
- Uses
get_or_createby default — preserves admin edits (like disabled plugins). - Supports
--mode updateto force-refresh descriptions and priorities.
⚙️ Step 5 — Use It Anywhere
from django_plugin_system.helpers import get_plugin_instance
def notify_user(user, message):
notifier = get_plugin_instance("notifier", "apps.notifications")
if notifier:
notifier.send(user, message)
✅ No imports, no hardcoding, and no redeploy.
Change the active notifier in Admin, and the system just works.
🎛 Advanced Use Case #1 — User Notification Preferences
You can even let users choose their favorite notification channels directly.
# models.py
from typing import List
from django.db import models
from django.contrib.auth.models import User
from django_plugin_system.models import PluginItem
class UserNotifyPref(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
favourite_plugins = models.ManyToManyField(PluginItem)
@staticmethod
def get_user_plugins(user: User) -> List[PluginItem]:
try:
return list(
UserNotifyPref.objects.get(user=user).favourite_plugins.all()
)
except UserNotifyPref.DoesNotExist:
return []
Usage:
from myapp.models import UserNotifyPref
def notify_user(user, message):
favourite_plugins = UserNotifyPref.get_user_plugins(user)
for plugin in favourite_plugins:
cls = plugin.load_class()
if cls:
cls().send(user, message)
💡 Result: Each user can customize their delivery channels —
Email + Push, Push only, or SMS only — all through data, not code.
📱 Advanced Use Case #2 — Multi-Provider OTP Failover
from django_plugin_system.models import PluginType, PluginItem, PluginStatus
from rest_framework.response import Response
def check_no_credit(result):
return getattr(result, "code", None) == "NO_CREDIT"
def notify_admin(msg):
print(f"[ADMIN ALERT] {msg}")
def _next_item(type_name: str, manager: str):
try:
pt = PluginType.objects.get(name=type_name, manager=manager)
except PluginType.DoesNotExist:
return None
return pt.get_single_plugin()
def send_otp(number: str, code: str) -> Response:
TYPE_NAME = "otp"
MANAGER = "otp_manager"
MAX_TRIES = 5
for _ in range(MAX_TRIES):
item = _next_item(TYPE_NAME, MANAGER)
if not item:
return Response({"detail": "Service unavailable"}, status=503)
cls = item.load_class()
if not cls:
item.status = PluginStatus.DISABLED
item.save(update_fields=["status"])
notify_admin(f"{item.module}.{item.name} failed to load")
continue
agent = cls()
try:
result = agent.send(number, code)
except Exception as exc:
item.status = PluginStatus.RESERVED
item.save(update_fields=["status"])
notify_admin(f"{item.module}.{item.name} crashed: {exc}")
continue
if getattr(result, "status", None) == 200:
return Response({"detail": "SMS sent successfully"}, status=200)
if check_no_credit(result):
item.status = PluginStatus.DISABLED
item.save(update_fields=["status"])
notify_admin(f"{item.module} is out of credit")
continue
item.priority = item.priority + 1
item.save(update_fields=["priority"])
return Response({"detail": "Service unavailable"}, status=503)
✅ Highlights: automatic provider failover, self-healing priority updates, and admin reactivation.
🧠 Design Philosophy
django-plugin-system turns your Django project into a modular ecosystem.
Define contracts once, implement anywhere, and control behavior dynamically.
Code once, configure forever.
💡 Inspiration
This library is heavily inspired by Drupal’s plugin system. Many of the ideas here — clear separation between plugin definition and instance configuration, runtime discovery, and admin-level control — echo patterns refined by the Drupal community.
🚀 Roadmap: Configurable, Multi-Instance Plugins
We’re evolving from “one implementation per plugin item” to configurable, multi-instance plugins. A single plugin class can have many instances (e.g., different credentials, endpoints, or templates), each with its own status, priority, and config.
This enables use-cases like “Transactional SMS vs Marketing SMS,” “US-based S3 vs EU-based S3,” etc.
v1.2: add PluginInstance (backward compatible; legacy item selection still works).
v2.0–2.4: deprecate item-only selectors, encourage instance-aware helpers.
v3.0: remove legacy item-only code paths.
📦 Project Info
PyPI: https://pypi.org/project/django-plugin-system/
GitHub: https://github.com/Alireza-Tabatabaeian/django-plugin-system
License: MIT
Compatible: Django 4.2+, Python 3.10+
Top comments (0)