Django Apps vs Projects Explained: A Complete Production Guide
From "what's the difference?" to packaging reusable apps and deploying production-grade multi-app architectures — a backend engineer's complete guide.
Table of Contents
- Introduction
- Why This Matters in Production Systems
- Core Concepts
- Architecture Design
- Step-by-Step Implementation
- Code Examples
- Performance Optimization
- Security Best Practices
- Common Developer Mistakes
- Real Production Use Cases
- Conclusion
Introduction
Ask ten Django developers to explain the difference between a project and an app, and you'll get ten different answers. Some will say "a project contains apps." Some will say "apps are like modules." A few will shrug and say "I just run startapp when I need a new feature."
They're all partially right — and all missing the deeper picture.
The project/app distinction in Django is not just a file-organisation convention. It is a software design principle implemented as a framework feature. It defines how code is scoped, how it is reused, how it is tested in isolation, how it scales across teams, and ultimately how well your codebase survives the passage of time and the accumulation of features.
An app is a web application that does something — a blog system, a database of public records, or a small poll app. A project is a collection of configuration and apps for a particular website. A project can contain multiple apps, and an app can be in multiple projects.
That definition from the official Django documentation is accurate, but thin. It tells you the vocabulary without telling you why it exists, how to use it well, or what happens when you get it wrong in a production codebase with ten engineers and two years of accumulated technical debt.
This guide goes all the way. We'll cover the conceptual distinction, the file-level mechanics, real production architecture patterns, reusable app packaging, performance, security, and the mistakes that cost teams weeks of refactoring.
Let's start from the beginning.
Why This Matters in Production Systems
Getting the project/app boundary wrong is one of the most expensive structural mistakes you can make in Django. Here's why it compounds over time.
Maintainability degrades non-linearly. When all your models, views, and business logic live in a single app — what engineers call a "god app" — every change has unpredictable blast radius. A change to the users model might break billing, orders, and notifications, but you only find out through integration tests or production errors.
Teams can't work in parallel. If five engineers are all working in the same main app, merge conflicts are constant and code review becomes territorial. Well-defined apps give teams ownership boundaries that reduce friction.
Reuse is impossible. Apps are reusable and can be used in multiple projects. Django follows a "pluggable" architecture, meaning you can plug apps into different projects easily. This modularity allows you to keep your codebase organized and maintainable. A notification system built as a proper Django app can be extracted and dropped into a second product. One built as a mess of views and models scattered across a god app cannot.
Testing is slower and less reliable. An app with clear boundaries can be tested in isolation. A tangled codebase requires spinning up the entire application to test any individual feature.
Scaling becomes architectural surgery. If your codebase eventually needs to split into microservices — or even just separate Django processes for different workloads — apps with clean boundaries can be extracted. Monolithic apps cannot.
The investment in understanding and correctly applying the project/app distinction pays compound interest over the lifetime of a codebase.
Core Concepts
What Is a Django Project?
A project is a top-level container for your web application. A project contains the configurations for your web application and one or more apps, each providing a specific set of functionalities or features. It also contains any components shared between its different apps.
More concretely, a Django project is defined by:
settings.py(or a settings package) — global configuration: database, cache, middleware, installed apps, authenticationurls.py— the root URL dispatcher that routes requests to appswsgi.py/asgi.py— the WSGI/ASGI entry point for production serversmanage.py— the project management CLI
A project is not reusable. It is environment-specific. It knows about the server it runs on, the database it talks to, the cloud storage it uses. You wouldn't package a project and put it on PyPI — you'd put an app there.
What Is a Django App?
A Django app is a self-contained component of a Django project. It is a module that provides specific functionality, such as handling authentication, managing blog posts, or serving an API. An app should represent a single, specific functionality or purpose within the overall website.
A Django app is defined by:
models.py— data structure and database schemaviews.py— request handling and response logicurls.py— URL patterns scoped to this appadmin.py— admin site registrationapps.py— theAppConfigclass, the app's identitymigrations/— database schema version historytests/— isolated test suite
An app is reusable. The Python Package Index has a vast range of packages you can use in your own Python programs. Django itself is also a normal Python package. This means that you can take existing Python packages or Django apps and compose them into your own web project. You only need to write the parts that make your project unique.
The AppConfig Class — The App's Identity
Every properly configured Django app has an AppConfig subclass in apps.py. This is the app's formal registration with Django's application registry:
# apps/orders/apps.py
from django.apps import AppConfig
class OrdersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.orders" # full Python dotted path
label = "orders" # unique short name used in migrations and DB tables
verbose_name = "Order Management"
def ready(self):
"""
Called once when Django starts.
Safe place to connect signals.
"""
import apps.orders.signals # noqa: F401
The ready() method is critically important for signal registration — more on this in the code examples section.
The INSTALLED_APPS Contract
The INSTALLED_APPS setting in your project's settings.py is the formal list of every app the project knows about. Apps not in this list are invisible to Django's ORM, migration system, admin, and template loader.
# config/settings/base.py
INSTALLED_APPS = [
# ── Django built-in apps ──────────────────────
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# ── Third-party apps (installed via pip) ──────
"rest_framework",
"django_filters",
"corsheaders",
"celery",
# ── Local project apps ────────────────────────
"apps.users.apps.UsersConfig",
"apps.catalog.apps.CatalogConfig",
"apps.orders.apps.OrdersConfig",
"apps.notifications.apps.NotificationsConfig",
"apps.payments.apps.PaymentsConfig",
]
Always reference apps using their AppConfig class (the dotted string ending in Config) rather than just the package path. This ensures ready() is called and the verbose_name is applied correctly.
Architecture Design
Here's a complete view of how a production Django project organises its apps, and how they relate to each other:
myproject/
│
├── config/ ← PROJECT (configuration only)
│ ├── settings/
│ │ ├── base.py ← Shared project settings
│ │ ├── development.py ← Local dev overrides
│ │ ├── production.py ← Production settings
│ │ └── test.py ← Test runner settings
│ ├── urls.py ← Root URL dispatcher
│ ├── wsgi.py
│ └── asgi.py
│
├── apps/ ← ALL APPS LIVE HERE
│ │
│ ├── users/ ← App: authentication & profiles
│ │ ├── migrations/
│ │ ├── tests/
│ │ ├── apps.py ← AppConfig
│ │ ├── models.py
│ │ ├── views.py
│ │ ├── serializers.py
│ │ ├── services.py ← Business logic
│ │ ├── selectors.py ← Read/query logic
│ │ ├── signals.py
│ │ ├── admin.py
│ │ └── urls.py
│ │
│ ├── catalog/ ← App: products & categories
│ │ └── ... (same structure)
│ │
│ ├── orders/ ← App: cart, checkout, order lifecycle
│ │ └── ...
│ │
│ ├── payments/ ← App: Stripe integration, invoices
│ │ └── ...
│ │
│ └── notifications/ ← App: email, SMS, push
│ └── ...
│
├── common/ ← SHARED UTILITIES (not a Django app)
│ ├── exceptions.py
│ ├── mixins.py
│ ├── pagination.py
│ └── permissions.py
│
├── infrastructure/ ← THIRD-PARTY INTEGRATIONS
│ ├── email/ ← SendGrid/SES wrapper
│ ├── storage/ ← S3 wrapper
│ └── payment/ ← Stripe/Braintree wrapper
│
├── requirements/
│ ├── base.txt
│ ├── development.txt
│ └── production.txt
│
├── manage.py
└── pyproject.toml
The Dependency Direction Rule
Apps should form a directed acyclic graph (DAG) of dependencies. No circular imports. No app should import from an app that ranks "above" it conceptually.
ALLOWED dependency direction:
notifications → users ✅ (notifications can send to users)
orders → catalog ✅ (orders reference products)
orders → users ✅ (orders belong to users)
payments → orders ✅ (payments are for orders)
FORBIDDEN:
users → orders ❌ (users shouldn't know about orders)
catalog → orders ❌ (products shouldn't know about orders)
If you find yourself wanting to import orders from users, you need a third app (like notifications) that both can depend on — or you need to use Django signals to decouple the relationship.
Step-by-Step Implementation
Step 1: Initialise the Project (Not an App)
# Create a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install Django
pip install django django-environ djangorestframework
# Create project — trailing dot puts manage.py at root level
django-admin startproject config .
# Create the apps directory
mkdir -p apps
touch apps/__init__.py
Step 2: Create Your First App
# Navigate into the apps directory and scaffold the app
cd apps
django-admin startapp users
django-admin startapp catalog
django-admin startapp orders
cd ..
Step 3: Update AppConfig for Each App
After creating apps inside the apps/ subdirectory, you must update the name attribute in each AppConfig to reflect the new path:
# apps/users/apps.py
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.users" # ← full dotted path from project root
label = "users" # ← unique short label (used in DB table names)
verbose_name = "Users & Authentication"
def ready(self):
import apps.users.signals # noqa: F401
Step 4: Wire Apps into the Project
# config/settings/base.py
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# third-party
"rest_framework",
# local apps — always use AppConfig
"apps.users.apps.UsersConfig",
"apps.catalog.apps.CatalogConfig",
"apps.orders.apps.OrdersConfig",
]
AUTH_USER_MODEL = "users.User"
Step 5: Compose App URLs in the Project Router
# config/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/users/", include("apps.users.urls", namespace="users")),
path("api/catalog/", include("apps.catalog.urls", namespace="catalog")),
path("api/orders/", include("apps.orders.urls", namespace="orders")),
path("api/payments/", include("apps.payments.urls", namespace="payments")),
]
Each app declares its own app_name for URL namespacing:
# apps/orders/urls.py
from django.urls import path
from apps.orders.views import OrderListView, OrderDetailView
app_name = "orders"
urlpatterns = [
path("", OrderListView.as_view(), name="list"),
path("<int:pk>/", OrderDetailView.as_view(), name="detail"),
]
# Reverse URL usage: reverse("orders:detail", kwargs={"pk": 42})
Step 6: Run and Validate
# Create and apply migrations for all installed apps
python manage.py makemigrations
python manage.py migrate
# Verify the project sees all apps
python manage.py shell -c "from django.apps import apps; print([a.name for a in apps.get_app_configs()])"
Code Examples
Complete App Model with Proper Relationships
# apps/orders/models.py
from django.db import models
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
User = get_user_model()
class Order(models.Model):
"""
Represents a customer order.
This model belongs to the 'orders' app. It references:
- apps.users → via ForeignKey to User
- apps.catalog → via OrderItem → Product
It does NOT import from those apps' models directly.
Cross-app references use ForeignKey strings ("catalog.Product")
to avoid circular imports.
"""
class Status(models.TextChoices):
PENDING = "pending", _("Pending")
PROCESSING = "processing", _("Processing")
SHIPPED = "shipped", _("Shipped")
DELIVERED = "delivered", _("Delivered")
CANCELLED = "cancelled", _("Cancelled")
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="orders",
verbose_name=_("customer"),
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
)
total = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = "orders" # ← explicit, matches AppConfig.label
ordering = ["-created_at"]
verbose_name = _("Order")
verbose_name_plural = _("Orders")
indexes = [
models.Index(fields=["user", "status"]),
models.Index(fields=["-created_at"]),
]
def __str__(self) -> str:
return f"Order #{self.pk} ({self.get_status_display()})"
class OrderItem(models.Model):
"""
Line item within an order.
References catalog.Product using a lazy string to avoid circular imports.
"""
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey("catalog.Product", on_delete=models.PROTECT, related_name="order_items")
quantity = models.PositiveIntegerField()
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
app_label = "orders"
@property
def subtotal(self):
return self.quantity * self.unit_price
Custom User Model (Always in the users App)
# apps/users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils.translation import gettext_lazy as _
class User(AbstractUser):
"""
Custom user model. ALWAYS defined in your own app.
ALWAYS set before the first migration. Non-negotiable.
django.contrib.auth is a third-party app (included with Django).
This app (apps.users) extends it for project-specific needs.
"""
email = models.EmailField(_("email address"), unique=True)
avatar = models.ImageField(upload_to="avatars/%Y/%m/", null=True, blank=True)
bio = models.TextField(blank=True)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"]
class Meta:
app_label = "users"
verbose_name = _("User")
verbose_name_plural = _("Users")
def __str__(self) -> str:
return self.email
Signals: Decoupling Apps Without Circular Imports
Signals are the primary mechanism for communication between apps without creating direct import dependencies. The orders app can trigger behaviour in the notifications app without importing it:
# apps/orders/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from apps.orders.models import Order
@receiver(post_save, sender=Order)
def on_order_status_changed(sender, instance: Order, created: bool, **kwargs):
"""
When an order is saved (created or updated), fire a domain event.
The notifications app listens to this signal — no direct coupling.
"""
from django.db import transaction
if created:
transaction.on_commit(
lambda: _dispatch_order_created(instance.id)
)
elif "status" in (kwargs.get("update_fields") or []):
transaction.on_commit(
lambda: _dispatch_order_status_changed(instance.id, instance.status)
)
def _dispatch_order_created(order_id: int) -> None:
from apps.notifications.tasks import send_order_confirmation
send_order_confirmation.delay(order_id)
def _dispatch_order_status_changed(order_id: int, new_status: str) -> None:
from apps.notifications.tasks import send_order_status_update
send_order_status_update.delay(order_id, new_status)
# apps/orders/apps.py
from django.apps import AppConfig
class OrdersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.orders"
label = "orders"
def ready(self):
# Import signals only here, in ready(), never at module level
import apps.orders.signals # noqa: F401
Building a Reusable, Packageable App
Here's how to structure an app so it can be lifted out of your project and published to PyPI or shared across your organisation's internal projects:
# django_notifications/apps.py
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "django_notifications"
label = "notifications"
verbose_name = "Notifications"
# Configurable via project settings
# Usage in consuming project: NOTIFICATIONS_EMAIL_BACKEND = "sendgrid"
def ready(self):
from django_notifications import checks # noqa: F401
import django_notifications.signals # noqa: F401
# django_notifications/conf.py
from django.conf import settings
DEFAULTS = {
"EMAIL_BACKEND": "django", # or "sendgrid", "ses"
"SMS_BACKEND": None,
"RETENTION_DAYS": 30,
}
class NotificationsSettings:
def __getattr__(self, name):
if name not in DEFAULTS:
raise AttributeError(f"Invalid setting: {name}")
user_settings = getattr(settings, "NOTIFICATIONS", {})
return user_settings.get(name, DEFAULTS[name])
app_settings = NotificationsSettings()
# pyproject.toml for a publishable Django app
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "django-my-notifications"
version = "1.0.0"
description = "A reusable Django notifications app"
requires-python = ">=3.11"
dependencies = [
"Django>=4.2",
]
classifiers = [
"Framework :: Django",
"Framework :: Django :: 5.0",
"Programming Language :: Python :: 3.11",
]
[project.urls]
Homepage = "https://github.com/yourorg/django-my-notifications"
System Checks: App-Level Validation at Startup
Apps can register system checks that run during python manage.py check. This is how Django built-in apps validate their configuration:
# apps/payments/checks.py
from django.core.checks import Error, Warning, register
@register()
def check_stripe_settings(app_configs, **kwargs):
"""
Validate required payment settings at startup,
not at runtime when a customer is trying to pay.
"""
from django.conf import settings
errors = []
if not getattr(settings, "STRIPE_SECRET_KEY", None):
errors.append(
Error(
"STRIPE_SECRET_KEY is not set.",
hint="Add STRIPE_SECRET_KEY to your settings.",
obj=settings,
id="payments.E001",
)
)
if not getattr(settings, "STRIPE_WEBHOOK_SECRET", None):
errors.append(
Warning(
"STRIPE_WEBHOOK_SECRET is not set.",
hint="Webhook signature verification will be disabled.",
obj=settings,
id="payments.W001",
)
)
return errors
# apps/payments/apps.py
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
name = "apps.payments"
label = "payments"
def ready(self):
from apps.payments import checks # registers the check
import apps.payments.signals
Performance Optimization
1. Lazy App Loading — Understand When Code Runs
AppConfig.ready() runs once, at startup, in the main process. Code here should be fast and should not make database calls or network requests. It's for registering signals, system checks, and in-process event handlers only.
# CORRECT: ready() registers signals, nothing else
def ready(self):
import apps.orders.signals
# WRONG: database call in ready() — will fail and slow startup
def ready(self):
from apps.users.models import User
count = User.objects.count() # ← Never do this in ready()
print(f"Users: {count}")
2. Per-App Database Routing
When different apps have different scaling requirements, use Django's database router to send reads and writes to different databases:
# config/routers.py
class AppDatabaseRouter:
"""
Routes read queries for the 'analytics' app to a read replica.
All other apps use the default (primary) database.
"""
ANALYTICS_APP = "analytics"
READ_REPLICA = "replica"
def db_for_read(self, model, **hints):
if model._meta.app_label == self.ANALYTICS_APP:
return self.READ_REPLICA
return None # None = use default
def db_for_write(self, model, **hints):
return None # Always write to default
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, **hints):
if app_label == self.ANALYTICS_APP:
return db == self.READ_REPLICA
return db == "default"
# config/settings/production.py
DATABASE_ROUTERS = ["config.routers.AppDatabaseRouter"]
DATABASES = {
"default": env.db("DATABASE_URL"),
"replica": env.db("DATABASE_REPLICA_URL"),
}
3. Migration Performance — Squash by App
Long-running migrations are a production deployment risk. Squash migrations on a per-app basis to keep the migration graph lean:
# Squash all migrations for the orders app into one
python manage.py squashmigrations orders 0001 0042
# Squash migrations up to a specific date
python manage.py squashmigrations catalog 0001 0018 --squashed-name=initial_schema
4. App-Scoped Caching with Key Prefixes
Isolate each app's cache namespace to prevent key collisions and enable targeted cache invalidation:
# apps/catalog/cache.py
from django.core.cache import cache
APP_CACHE_PREFIX = "catalog"
PRODUCT_LIST_KEY = f"{APP_CACHE_PREFIX}:products:active:v2"
CATEGORY_KEY = f"{APP_CACHE_PREFIX}:categories:v1"
def get_cached_active_products():
result = cache.get(PRODUCT_LIST_KEY)
if result is None:
from apps.catalog.selectors import get_active_products
result = list(get_active_products().values("id", "name", "slug", "price"))
cache.set(PRODUCT_LIST_KEY, result, timeout=3600)
return result
def invalidate_product_cache():
"""Call this from admin save signals or management commands."""
cache.delete_many([PRODUCT_LIST_KEY, CATEGORY_KEY])
Security Best Practices
1. App-Level Permission Classes
Each app should define its own permission classes rather than relying solely on global DRF settings:
# apps/orders/permissions.py
from rest_framework.permissions import BasePermission
class IsOrderOwner(BasePermission):
"""
Object-level permission. Grants access only if the requesting
user is the owner of the order.
Always use this on order detail endpoints.
Never rely on URL filtering alone — that's IDOR.
"""
message = "You do not have permission to access this order."
def has_object_permission(self, request, view, obj):
return obj.user_id == request.user.id
class IsOrderOwnerOrAdmin(BasePermission):
def has_object_permission(self, request, view, obj):
return (
obj.user_id == request.user.id
or request.user.is_staff
)
2. App-Scoped Model Managers for Data Isolation
Never trust raw querysets on multi-tenant data. Use custom managers that enforce ownership at the ORM layer:
# apps/orders/models.py
from django.db import models
class UserScopedManager(models.Manager):
"""
A manager that always filters by the current user.
Prevents IDOR by making it structurally impossible
to query another user's data through this manager.
"""
def for_user(self, user):
return self.get_queryset().filter(user=user)
def for_user_or_404(self, user, **kwargs):
from django.shortcuts import get_object_or_404
return get_object_or_404(self.for_user(user), **kwargs)
class Order(models.Model):
# ...
objects = UserScopedManager()
# Usage — impossible to accidentally access another user's orders:
orders = Order.objects.for_user(request.user)
order = Order.objects.for_user_or_404(request.user, pk=42)
3. Per-App SECRET_KEY Rotation Strategy
Use separate secrets for different security contexts within your project:
# config/settings/production.py
import environ
env = environ.Env()
# Main Django secret key — for session signing, CSRF tokens
SECRET_KEY = env("SECRET_KEY")
# Separate signing key for password reset tokens
# Can be rotated without invalidating sessions
PASSWORD_RESET_SIGNING_KEY = env("PASSWORD_RESET_SIGNING_KEY")
# API token signing — separate from main key
API_TOKEN_SIGNING_KEY = env("API_TOKEN_SIGNING_KEY")
# apps/users/services.py
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.conf import settings
class PasswordResetService:
SIGNER = TimestampSigner(key=settings.PASSWORD_RESET_SIGNING_KEY, salt="password-reset")
@classmethod
def generate_token(cls, user) -> str:
return cls.SIGNER.sign(str(user.pk))
@classmethod
def validate_token(cls, token: str, max_age_seconds: int = 3600) -> int:
"""Returns user_id or raises BadSignature/SignatureExpired."""
value = cls.SIGNER.unsign(token, max_age=max_age_seconds)
return int(value)
Common Developer Mistakes
❌ Mistake 1: The God App
# BAD — one app that does everything
apps/
└── main/
├── models.py ← User, Product, Order, Payment, all in here
├── views.py ← 3,000 lines, every endpoint
└── urls.py ← 200 URL patterns
# GOOD — each domain has its own app
apps/
├── users/
├── catalog/
├── orders/
└── payments/
If your models.py has more than ~10 model classes, or your views.py has more than ~20 view functions, your app is doing too much.
❌ Mistake 2: Circular App Imports
# BAD — circular import between apps
# apps/orders/models.py
from apps.users.models import User # orders depends on users ✅
# apps/users/models.py
from apps.orders.models import Order # users depends on orders ❌ — CIRCULAR
# GOOD — use ForeignKey string reference (lazy import)
# apps/orders/models.py
class Order(models.Model):
user = models.ForeignKey(
"users.User", # ← string reference, no direct import
on_delete=models.CASCADE
)
❌ Mistake 3: Forgetting app_label on Abstract Models
# BAD — abstract model without app_label confuses migrations
class TimestampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
# Missing: no app_label issue for abstract, but concrete subclasses
# need explicit app_label if they're in apps/ subdirectory
# GOOD — concrete models always declare app_label explicitly
class Order(TimestampedModel):
class Meta(TimestampedModel.Meta):
app_label = "orders"
❌ Mistake 4: Importing from Apps in settings.py
# BAD — importing from an app in settings.py causes early import errors
# config/settings/base.py
from apps.users.constants import DEFAULT_PERMISSIONS # ← Django not ready yet!
# GOOD — reference by string, or use lazy imports inside functions
AUTH_USER_MODEL = "users.User" # string reference — safe
❌ Mistake 5: Putting Business Logic in apps.py ready()
# BAD — database call in ready()
class OrdersConfig(AppConfig):
def ready(self):
from apps.orders.models import Order
Order.objects.filter(status="stuck").update(status="pending") # ❌
# GOOD — ready() is for signal registration only
class OrdersConfig(AppConfig):
def ready(self):
import apps.orders.signals # ✅
❌ Mistake 6: Duplicate App Labels
# BAD — two apps with the same label
INSTALLED_APPS = [
"apps.v1.payments.apps.PaymentsConfig", # label = "payments"
"apps.v2.payments.apps.PaymentsConfig", # label = "payments" ← COLLISION
]
# GOOD — give each a unique label in AppConfig
class PaymentsV2Config(AppConfig):
name = "apps.v2.payments"
label = "payments_v2" # ← unique
Real Production Use Cases
Use Case 1: E-Commerce Platform — 6 Apps, Clean Boundaries
apps/
├── users/ → Authentication, profiles, addresses
├── catalog/ → Products, categories, variants, inventory
├── orders/ → Cart, checkout, order lifecycle, fulfillment
├── payments/ → Stripe integration, invoices, refunds
├── shipping/ → Carrier integration, tracking, labels
└── notifications/ → Email (SES), SMS (Twilio), push notifications
Cross-app communication pattern:
User places order
↓
orders.views.CheckoutView
↓ calls
orders.services.OrderService.create() ← business logic
↓ creates Order, fires post_save signal
↓
orders.signals.on_order_created()
↓ uses transaction.on_commit to defer:
├── payments.tasks.charge_card.delay()
├── inventory.tasks.reserve_stock.delay()
└── notifications.tasks.send_confirmation.delay()
No app imports another app's internal models or services directly. All cross-app communication is through signals and Celery tasks.
Use Case 2: SaaS Multi-Tenant — Shared App, Project-Level Config
A tenancy app is built once and shared across three separate Django projects (three different SaaS products in the same organisation):
# Package: django-company-tenancy (internal PyPI)
# apps/tenancy/models.py
class Tenant(models.Model):
name = models.CharField(max_length=255)
domain = models.CharField(max_length=255, unique=True)
plan = models.CharField(max_length=50)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = "tenancy"
# Each project's requirements/base.txt
django-company-tenancy==2.3.1 # ← reusable app, versioned
Use Case 3: Microservice Extraction — App Becomes a Service
An organisation's notifications app starts as part of a monolith. As it grows:
- It's first extracted into a standalone
django-internal-notificationspackage (reusable app) - Later, it becomes a fully separate Django project (microservice) with its own database and API
- The other apps that previously sent signals now call the notifications API over HTTP
This migration is only possible because the notifications app was built with clean boundaries from day one — no circular imports, no shared models with other apps, all cross-app communication through signals and tasks.
Use Case 4: Open-Source App — From Internal to PyPI
# Structure for a publishable Django app
django-audit-log/
├── django_audit_log/ ← the app package
│ ├── __init__.py
│ ├── apps.py
│ ├── models.py
│ ├── middleware.py
│ ├── migrations/
│ └── tests/
├── tests/ ← project-level tests for the package
│ ├── settings.py ← minimal test project settings
│ └── test_middleware.py
├── pyproject.toml
├── README.md
└── CHANGELOG.md
# tests/settings.py — minimal settings for testing the app in isolation
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.auth",
"django_audit_log", # ← only the app being tested
]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
SECRET_KEY = "test-secret-key-not-for-production"
Conclusion
The distinction between a Django project and a Django app is one of the framework's most powerful design decisions — and one of the most consistently underutilised by developers who treat it as mere file organisation.
A project is configuration. It knows where the database lives, what middleware runs, which apps are installed, and how the environment is configured. It is specific, environment-aware, and non-reusable.
An app is behaviour. It owns a single, cohesive domain of responsibility. It is portable, testable in isolation, independently maintainable, and — when built well — reusable across multiple projects.
Keep each app focused on one domain concept to avoid a god app. Put reusable utilities in a common package rather than sprinkling helpers across apps.
The production patterns in this guide — directed dependency graphs, signal-based inter-app communication, custom managers, AppConfig system checks, reusable app packaging — all follow from one principle: an app should be able to stand alone. If it can stand alone, it can be reasoned about. If it can be reasoned about, it can be tested, maintained, scaled, and eventually extracted.
Build your next Django application with this in mind, and the code you ship today will still be navigable, extensible, and maintainable in two years.
That's the real difference between an app and a project — and between a codebase that ages well and one that doesn't.
Further Reading
- Django Documentation: Apps
- Django Documentation: Writing Reusable Apps
- Django Best Practices: Projects vs. Apps — LearnDjango
- HackSoft Django Styleguide
- Real Python: How to Write an Installable Django App
- Django Packages Directory
Written by a Python backend engineer building production Django systems. Topics covered: Django project structure, Django apps, AppConfig, reusable apps, multi-app architecture, signals, database routing, packaging.
Comments
Post a Comment