Build a Scalable Django DDD Project Fast: A Practical Python Scaffold with Django 5, DRF, Dual Databases, and Domain Events

This is a Python DDD scaffold built on Django 5 and DRF. Its core capabilities include a four-layer architecture, dual database routing, unified responses, global exception handling, and domain event publishing. It addresses common Django pain points such as scattered business rules, bloated models, and excessive framework coupling. Keywords: Django DDD, dual databases, domain events.

The technical specification snapshot is concise and practical

Parameter Value
Language Python 3.10+
Web Framework Django 5.1
API Protocol HTTP/REST, OpenAPI 3
Core Dependencies DRF 3.15, drf-spectacular 0.27
Databases MySQL 8.0+, PostgreSQL 13+
Event Mechanism In-process publisher, extensible to Kafka/RocketMQ
Repository URL https://github.com/microwind/design-patterns/tree/main/practice-projects/django-ddd
Star Count Not provided in the source

This scaffold targets medium to large Django engineering scenarios

Traditional Django is highly productive for small projects. But as the number of modules, APIs, and business rules grows quickly, common problems tend to surface at once: decision logic gets scattered across Views, Models, and Services; tests depend heavily on the ORM runtime; and replacing infrastructure becomes expensive.

This scaffold adopts a pragmatic DDD approach. It does not pursue conceptual complexity for its own sake. Instead, it focuses on four essentials: layering, domain models, repository abstraction, and domain events. This preserves Django ecosystem productivity while giving the business model stronger cohesion.

It solves several common engineering pain points

  • Business rules no longer leak into the API layer and ORM models
  • The domain layer uses pure Python dataclasses, which makes unit testing lighter
  • The application layer focuses only on use case orchestration and transaction boundaries
  • The infrastructure layer provides capabilities inward through repository interfaces, reducing framework coupling
from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float

    @classmethod
    def create(cls, name: str, price: float) -> "Product":
        if not name:  # Centralize business validation in the aggregate root
            raise ValueError("商品名不能为空")
        if price <= 0:  # Prevent invalid business states from entering the system
            raise ValueError("价格必须大于 0")
        return cls(name=name, price=price)

This code shows how a domain object encapsulates rules at creation time instead of pushing validation into a view function.

The four-layer architecture forms the scaffold’s stable core

The project is explicitly divided into the interface layer, application layer, domain layer, and infrastructure layer, and it consistently follows the rule that outer layers depend on inner layers. The most important principle is that the domain layer never imports Django, which allows the core business logic to evolve independently from the web framework.

The responsibility boundaries across the four layers are very clear

Layer Directory Core Responsibility
Interface Layer */interfaces/ Handles HTTP, Serializers, APIView, and URLs
Application Layer */application/ Use case orchestration, DTOs, Commands, and transaction boundaries
Domain Layer */domain/ Aggregate roots, repository interfaces, domain events, and rule encapsulation
Infrastructure Layer */infrastructure/ ORM, repository implementations, event publishers, configuration, and databases

A create-order request flows through the system in a predictable way

After the client sends POST /api/orders, the request first completes input validation in the interface layer and is then converted into a Command object. The application service coordinates user existence checks and aggregate creation, then passes the result to the repository for persistence and triggers domain event publishing.

class OrderApplicationService:
    def __init__(self, repository, publisher):
        self._repo = repository
        self._publisher = publisher

    def create(self, cmd):
        order = Order.create(cmd.user_id, cmd.amount)  # Handle business rules only inside the aggregate root
        saved = self._repo.save(order)  # Isolate ORM details through the repository interface
        self._publisher.publish(saved.events)  # Publish domain events without polluting the main flow
        return saved

This code demonstrates that the application layer is responsible for orchestration, not for carrying specific business decisions.

The dual-database design makes bounded context isolation more natural

By default, the scaffold separates the user database from the order database: MySQL maps to user, and PostgreSQL maps to order. This bounded-context-based split is well suited for future service decomposition and independent storage evolution.

The implementation is not complex. The key is that a Django Router automatically routes read and write requests based on app_label. The business layer does not need to know anything about database switching, so when you add a third context, you only need to add one more mapping.

APP_DB_MAPPING = {
    "user": "default",      # Route the user context to MySQL
    "order": "order_db",   # Route the order context to PostgreSQL
}

class AppLabelRouter:
    def db_for_read(self, model, **hints):
        return APP_DB_MAPPING.get(model._meta.app_label, "default")

    db_for_write = db_for_read

The value of this routing code lies in collapsing multi-database complexity into a single configuration point.

The domain event mechanism improves extensibility in the main workflow

The scaffold defines an EventPublisher abstraction in the shared layer, with an in-process publisher as the default implementation. The benefit is straightforward: business code depends only on the event interface, so switching to Kafka, RocketMQ, or RabbitMQ later does not require changes in the application layer.

Actions such as order creation, payment, shipment, and refund can all emit events inside aggregate roots. Listeners are registered by each business context in AppConfig.ready(). The shared layer does not need to know specific business event types, which keeps autonomy boundaries clear.

Event publisher wiring stays intentionally lightweight

class SharedConfig(AppConfig):
    name = "shared"

    def ready(self):
        kind = getattr(settings, "DDD_EVENT_PUBLISHER_KIND", "memory")
        configure_publisher(kind)  # Wire the publisher implementation based on configuration

This initialization code reflects an infrastructure design principle: stable abstractions with replaceable implementations.

Unified responses and global exception handling reduce API governance costs

The project standardizes all responses into the { code, message, data } structure, so success and failure responses share the same format. This means frontend clients and downstream consumers do not need to adapt to multiple protocols, and Django’s default exception output no longer risks exposing internal stack details.

The domain layer can raise DomainError, ValidationError, and NotFoundError, which are then wrapped uniformly by a DRF global exception handler. For team collaboration, this is far more robust than adding manual try/except logic in every View.

REST_FRAMEWORK = {
    "EXCEPTION_HANDLER": "shared.infrastructure.exceptions.global_exception_handler",  # Intercept and wrap exception responses uniformly
}

The significance of this configuration is that it extracts error handling from controller logic and standardizes API governance.

The path for adding a new business module is highly repeatable

Take a new product module as an example. The recommended order is: define the aggregate root first, then define the repository interface, then complete the ORM model and repository implementation, and finally add the application service, interface routes, and database tables. This sequence ensures that the core business model stabilizes before technical implementation details.

This directory convention becomes especially valuable in multi-developer teams. As soon as someone sees the domain/application/infrastructure/interfaces structure, they can immediately determine where code belongs, which reduces coordination overhead around where logic should live.

The recommended minimal module skeleton is straightforward

class ProductApplicationService:
    def __init__(self, repository):
        self._repo = repository

    def create(self, cmd):
        product = Product.create(cmd.name, cmd.price, cmd.stock)  # Enter the domain model first to validate rules
        return self._repo.save(product)  # Then persist it through the repository

This code demonstrates the minimum closed loop for onboarding a new module: DTO input, domain creation, and repository persistence.

The images mainly serve branding and sharing guidance

Cnblogs logo

This image is the Cnblogs site logo used for page branding.

WeChat sharing prompt

AI Visual Insight: This image prompts users to trigger the share action from the top-right corner of the page. It is a product interaction guide rather than a project architecture diagram, flowchart, or code design diagram, so it does not convey specific technical implementation details.

This scaffold works well as a team-level Django template

If your project is still in a single-app, low-endpoint, light-rule phase, a traditional Django layered structure is usually enough. But once you move into multi-context, multi-database, complex state machine, and event-driven integration scenarios, this DDD scaffold can significantly reduce later refactoring costs.

Its greatest value is not that it has more features, but that it makes boundaries explicit. That makes AI-generated code, human code review, and team-wide engineering conventions much easier to implement consistently.

FAQ structured Q&A

Q1: Does a Python project really need DDD?

Yes, but only when business complexity is high enough. DDD is not a language issue; it is a scale issue. When you have many modules, complex rules, and frequent team collaboration, DDD can significantly reduce coupling and maintenance cost.

Q2: If I only want one database, will this scaffold feel too heavy?

No. You can simply point every entry in APP_DB_MAPPING to default, and the project degrades cleanly into single-database mode. The architectural boundaries remain intact, and you can still expand to multiple databases later without major rewrites.

Q3: Will switching to Kafka or RocketMQ later affect business code?

No. The business layer depends only on the EventPublisher abstraction. You only need to add a new message middleware implementation and extend the publisher wiring function with one more branch. The upper application services and domain models can remain unchanged.

Core summary

This article refactors and analyzes a Python DDD scaffold built on Django 5, DRF, and drf-spectacular. It walks through the four-layer architecture, dual database routing, unified responses, domain events, and module extension patterns, helping teams quickly establish a testable, low-coupling engineering template suited for medium to large business evolution.