DEV Community

Cover image for Building a Pluggable Architecture in Django — Introducing django-plugin-system
alireza tabatabaeian
alireza tabatabaeian

Posted on

Building a Pluggable Architecture in Django — Introducing django-plugin-system

🧩 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
Enter fullscreen mode Exit fullscreen mode

Add it to your project:

INSTALLED_APPS = [
    # ...
    "django_plugin_system",
]
Enter fullscreen mode Exit fullscreen mode

Run migrations:

python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

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): ...
Enter fullscreen mode Exit fullscreen mode

🪄 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.)",
        })
Enter fullscreen mode Exit fullscreen mode

🧩 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",
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • Uses get_or_create by default — preserves admin edits (like disabled plugins).
  • Supports --mode update to 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)
Enter fullscreen mode Exit fullscreen mode

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 []
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

💡 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)
Enter fullscreen mode Exit fullscreen mode

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)