A software engineer's guide to structuring Django projects that scale — built from real-world experience shipping production systems.
Originally published at: https://alansomathewdev.blogspot.com/2026/03/django-project-structure-best-practices.html
Table of Contents
- Introduction
- Core Concepts
- Architecture Overview
- Step-by-Step Implementation
- Code Examples
- Performance and Scalability Tips
- Common Mistakes to Avoid
- Real-World Use Cases
- Best Practices Summary
- Conclusion
Introduction
Every Django developer has been there: you clone a year-old project, open the repository, and immediately face a wall of confusion. Files scattered at the root level, a single settings.py trying to serve three environments, models buried inside unrelated apps, and business logic crammed into views. The project works — but barely, and only if you already know where everything lives.
Django's famously quick onboarding is also its quiet danger. The framework's startproject scaffold is designed to get you moving, not to get you scaling. For small personal projects, the default layout is perfectly fine. But the moment your codebase grows to more than one developer, one environment, or one domain of responsibility, you need intentional structure.
Django project structure isn't just about aesthetics or personal preference. It directly impacts:
- Onboarding speed — how quickly a new developer becomes productive
- Maintainability — how safely you can change one component without breaking another
- Testability — how easily you can write isolated, fast unit tests
- Deployability — how cleanly you can promote code from development to staging to production
- Scalability — how painlessly you can extract a bounded domain into a microservice later
This article walks through production-grade Django project structure from first principles. You'll learn not just the patterns, but the reasoning behind them — which is what separates a developer who follows rules from one who can design their own.
Core Concepts
Before diving into the file tree, it's essential to internalize a few foundational ideas that drive every structural decision.
The Twelve-Factor App
The Twelve-Factor App methodology defines principles for building software-as-a-service apps that are portable, scalable, and maintainable. Several factors directly influence Django project structure:
- Config — store configuration in the environment, not in code
- Dev/prod parity — keep development and production as similar as possible
- Dependencies — explicitly declare and isolate dependencies
A well-structured Django project is, at its core, a Twelve-Factor App.
Separation of Concerns
Each module, file, and class should have one reason to change. In Django terms:
- Models define data shape and storage — nothing else
- Views handle HTTP request/response — nothing else
- Services contain business logic — separate from HTTP and data layers
- Serializers/Forms handle data validation and transformation
When business logic leaks into views, or database queries live inside templates, you've violated this principle. The cost compounds quickly.
Apps as Bounded Contexts
Django apps are the primary unit of code organization. Each app should map to a single, cohesive domain concept — a bounded context in Domain-Driven Design (DDD) terms.
A good Django app:
- Has a clear, single responsibility
- Can be reasoned about independently
- Has minimal coupling to other apps
- Could theoretically be extracted into a package
If your app is named utils, helpers, or misc, that's a signal it's doing too much.
Architecture Overview
Here's a bird's-eye view of a production-ready Django project:
myproject/
│
├── config/ # Project configuration (not an app)
│ ├── __init__.py
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── base.py # Shared settings
│ │ ├── development.py # Dev overrides
│ │ ├── production.py # Production overrides
│ │ └── test.py # Test-specific settings
│ ├── urls.py # Root URL configuration
│ ├── wsgi.py
│ └── asgi.py
│
├── apps/ # All Django apps live here
│ ├── users/
│ │ ├── migrations/
│ │ ├── tests/
│ │ │ ├── __init__.py
│ │ │ ├── test_models.py
│ │ │ ├── test_views.py
│ │ │ └── test_services.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── models.py
│ │ ├── serializers.py
│ │ ├── services.py # Business logic layer
│ │ ├── selectors.py # Read/query logic
│ │ ├── urls.py
│ │ └── views.py
│ │
│ └── orders/
│ └── ...
│
├── common/ # Shared utilities, base classes, mixins
│ ├── __init__.py
│ ├── exceptions.py
│ ├── mixins.py
│ ├── pagination.py
│ └── permissions.py
│
├── infrastructure/ # Third-party integrations
│ ├── email/
│ ├── storage/
│ └── payment/
│
├── templates/ # Global templates (if server-rendered)
├── static/ # Static assets
├── media/ # User-uploaded content (local dev only)
│
├── requirements/
│ ├── base.txt
│ ├── development.txt
│ └── production.txt
│
├── scripts/ # Management scripts, data migrations
├── docs/ # Project documentation
│
├── .env.example # Template for local environment variables
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── Makefile
├── manage.py
└── pyproject.toml # or setup.cfg / tox.ini
This layout has a clear hierarchy of responsibility:
-
config/owns project-level configuration -
apps/owns all domain logic -
common/owns reusable infrastructure shared across apps -
infrastructure/owns third-party integrations
Step-by-Step Implementation
Step 1: Initialize with a config Package
The default startproject creates a folder named after your project that doubles as both the Python package and the configuration directory. This conflation causes confusion. Separate them.
django-admin startproject config .
Notice the trailing dot — this places manage.py at the root and creates config/ as your settings package rather than duplicating your project name.
Step 2: Split Settings by Environment
Never use a single settings.py in production. The classic pattern uses a settings/ package with environment-specific modules:
mkdir config/settings
touch config/settings/__init__.py
touch config/settings/base.py
touch config/settings/development.py
touch config/settings/production.py
touch config/settings/test.py
Set the correct settings module per environment using the DJANGO_SETTINGS_MODULE environment variable:
# .env (local development)
DJANGO_SETTINGS_MODULE=config.settings.development
# Production server / CI
DJANGO_SETTINGS_MODULE=config.settings.production
Step 3: Place All Apps Under an apps/ Directory
Django's startapp places apps at the root level by default. This is fine for toy projects but creates a flat, hard-to-navigate root as you scale.
mkdir apps
cd apps
django-admin startapp users
django-admin startapp orders
Then update apps.py in each app 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
label = "users"
And update INSTALLED_APPS in your settings:
INSTALLED_APPS = [
# Django internals
"django.contrib.admin",
"django.contrib.auth",
# ...
# Third-party
"rest_framework",
"django_filters",
# Local apps
"apps.users.apps.UsersConfig",
"apps.orders.apps.OrdersConfig",
]
Step 4: Separate Business Logic into a Service Layer
This is the single highest-leverage structural change you can make. Views should be thin HTTP adapters; services should own business logic.
# apps/orders/views.py ← THIN
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from apps.orders.serializers import CreateOrderSerializer
from apps.orders.services import OrderService
class OrderCreateView(APIView):
def post(self, request):
serializer = CreateOrderSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
order = OrderService.create_order(
user=request.user,
**serializer.validated_data,
)
return Response({"id": order.id}, status=status.HTTP_201_CREATED)
# apps/orders/services.py ← BUSINESS LOGIC
from django.db import transaction
from apps.orders.models import Order, OrderItem
from apps.inventory.selectors import get_product_by_id
from infrastructure.payment.gateway import charge_card
class OrderService:
@staticmethod
@transaction.atomic
def create_order(user, product_id, quantity, payment_token):
product = get_product_by_id(product_id)
if product.stock < quantity:
raise ValueError("Insufficient stock")
order = Order.objects.create(user=user, total=product.price * quantity)
OrderItem.objects.create(order=order, product=product, quantity=quantity)
charge_card(token=payment_token, amount=order.total)
product.reduce_stock(quantity)
return order
Why this matters: your business logic is now testable without an HTTP request, reusable from management commands, Celery tasks, or other services, and readable without HTTP noise.
Step 5: Add a Selectors Layer for Queries
Following the HackSoft Django styleguide pattern, separate write logic (services) from read logic (selectors):
# apps/orders/selectors.py
from django.db.models import QuerySet
from apps.orders.models import Order
def get_orders_for_user(user_id: int) -> QuerySet:
return (
Order.objects
.filter(user_id=user_id)
.select_related("user")
.prefetch_related("items__product")
.order_by("-created_at")
)
This keeps views lean, makes query optimization localized, and makes read paths testable in isolation.
Code Examples
Base Settings Pattern
# config/settings/base.py
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve().parent.parent.parent
env = environ.Env()
environ.Env.read_env(BASE_DIR / ".env")
SECRET_KEY = env("SECRET_KEY")
DEBUG = env.bool("DEBUG", default=False)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
DATABASES = {
"default": env.db("DATABASE_URL")
}
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.users.apps.UsersConfig",
]
AUTH_USER_MODEL = "users.User"
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "common.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
}
# config/settings/development.py
from .base import *
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS += ["debug_toolbar"]
INTERNAL_IPS = ["127.0.0.1"]
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# config/settings/production.py
from .base import *
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
},
"staticfiles": {
"BACKEND": "storages.backends.s3boto3.S3StaticStorage",
},
}
Custom User Model (Set Up From Day One)
# apps/users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
"""
Custom user model. Always define this before your first migration.
Changing it later is painful beyond words.
"""
email = models.EmailField(unique=True)
avatar = models.ImageField(upload_to="avatars/", null=True, blank=True)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"]
class Meta:
verbose_name = "User"
verbose_name_plural = "Users"
def __str__(self):
return self.email
Structured Requirements Files
# requirements/base.txt
Django==5.0.4
djangorestframework==3.15.1
django-environ==0.11.2
psycopg2-binary==2.9.9
celery==5.3.6
redis==5.0.3
django-filter==24.2
Pillow==10.3.0
# requirements/development.txt
-r base.txt
django-debug-toolbar==4.3.0
pytest-django==4.8.0
factory-boy==3.3.0
ipython==8.23.0
black==24.3.0
ruff==0.4.1
# requirements/production.txt
-r base.txt
gunicorn==22.0.0
django-storages[s3]==1.14.3
sentry-sdk[django]==1.44.1
Testing Structure
# apps/orders/tests/test_services.py
import pytest
from django.contrib.auth import get_user_model
from apps.orders.services import OrderService
from apps.orders.tests.factories import ProductFactory
User = get_user_model()
@pytest.mark.django_db
class TestOrderService:
def test_create_order_success(self, user, mocker):
product = ProductFactory(stock=10, price=99.99)
mock_charge = mocker.patch("infrastructure.payment.gateway.charge_card")
order = OrderService.create_order(
user=user,
product_id=product.id,
quantity=2,
payment_token="tok_test",
)
assert order.total == 199.98
mock_charge.assert_called_once()
product.refresh_from_db()
assert product.stock == 8
def test_create_order_raises_on_insufficient_stock(self, user):
product = ProductFactory(stock=1)
with pytest.raises(ValueError, match="Insufficient stock"):
OrderService.create_order(
user=user,
product_id=product.id,
quantity=5,
payment_token="tok_test",
)
Performance and Scalability Tips
1. Use select_related and prefetch_related in Selectors
Every N+1 query problem you'll encounter can be traced to forgetting these. Centralizing queries in selectors means you fix it in one place:
# Always optimize at the selector level, not the view level
def get_order_list(user_id: int):
return (
Order.objects.filter(user_id=user_id)
.select_related("user", "shipping_address")
.prefetch_related("items__product__category")
.only("id", "status", "total", "created_at") # defer heavy fields
)
2. Index Strategically
Add database indexes on fields used in filter(), order_by(), and get() calls:
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True)
status = models.CharField(max_length=20, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
indexes = [
models.Index(fields=["user", "status"]), # composite index
models.Index(fields=["-created_at"]),
]
3. Offload to Celery
Any operation that doesn't need to complete within the HTTP request cycle should be async:
# apps/orders/tasks.py
from celery import shared_task
from apps.orders.services import OrderService
@shared_task(bind=True, max_retries=3)
def send_order_confirmation_email(self, order_id: int):
try:
OrderService.send_confirmation(order_id)
except Exception as exc:
raise self.retry(exc=exc, countdown=60)
4. Cache Aggressively at the Right Layer
Cache at the selector or service layer, not the view layer:
from django.core.cache import cache
def get_active_categories():
cache_key = "active_categories"
result = cache.get(cache_key)
if result is None:
result = list(Category.objects.filter(is_active=True).values("id", "name"))
cache.set(cache_key, result, timeout=3600)
return result
5. Use Database Connection Pooling
In production, configure pgBouncer or use django-db-geventpool. Unpooled connections are one of the fastest paths to production incidents under load.
Common Mistakes to Avoid
❌ Fat Views
# BAD — view doing way too much
def create_order(request):
product = Product.objects.get(id=request.POST["product_id"])
if product.stock < int(request.POST["quantity"]):
return JsonResponse({"error": "Out of stock"}, status=400)
order = Order.objects.create(user=request.user, ...)
send_mail("Order confirmed", ..., [request.user.email])
return JsonResponse({"id": order.id})
Move business logic to services. Views should be 5–15 lines.
❌ Not Defining a Custom User Model Upfront
Django's docs say it explicitly: always define a custom user model before your first migration. If you don't, retrofitting one later requires raw SQL surgery on your database. It takes under 10 minutes to set up and can save hours of future pain.
❌ Hardcoding Settings
# BAD
SECRET_KEY = "my-secret-key-12345"
DATABASES = {"default": {"HOST": "localhost", "PASSWORD": "postgres"}}
Use django-environ or python-decouple to read from environment variables. Never commit secrets to version control.
❌ Mega-Apps
An api/ app or core/ app that contains everything defeats the purpose of apps. If your models file is 2,000 lines, your app is doing too much. Split by domain, not by Django layer.
❌ Missing __init__.py or Misconfigured App Labels
When you move apps into a subdirectory, update both name (full dotted path) and label (short unique name) in AppConfig. Duplicate labels cause subtle, hard-to-debug errors.
❌ Skipping Migrations for Test Speed
Using --no-migrations in tests can cause false positives. Use pytest-django with a proper test database instead. For speed, use --reuse-db from pytest-django.
Real-World Use Cases
SaaS Multi-Tenant Platform
A SaaS product might organize apps like this:
apps/
├── accounts/ # Tenants / organizations
├── billing/ # Subscriptions, invoices (Stripe integration)
├── users/ # User auth within tenants
├── projects/ # Core product domain
├── notifications/ # Email, in-app, push
└── analytics/ # Usage tracking
Each app maps to a product domain. The billing/ app talks to Stripe via infrastructure/payment/. The notifications/ app is consumed by projects/ and billing/ via service calls, never direct model access.
E-Commerce Backend
apps/
├── catalog/ # Products, categories, variants
├── inventory/ # Stock management
├── orders/ # Cart, checkout, order lifecycle
├── shipping/ # Fulfillment, tracking
├── customers/ # User accounts, addresses
└── promotions/ # Discount codes, campaigns
Each bounded context owns its own models. The orders/ service calls inventory/ selectors to check stock. It never imports inventory models directly — only through the public selector API.
Internal Admin Tool
Smaller teams building internal tools can simplify:
apps/
├── core/ # Shared models, abstract base classes
├── employees/ # HR data
├── finance/ # Budget tracking
└── reporting/ # Dashboards, exports
The key remains the same: one app, one concern.
Best Practices Summary
| Area | Best Practice |
|---|---|
| Settings | Split into base/dev/prod/test; use environment variables |
| Apps | Group under apps/; one app per domain; avoid mega-apps |
| Business Logic | Always in services.py; never in views or models |
| Queries | Centralized in selectors.py; optimize with select_related
|
| User Model | Always define a custom user model before migration 0001 |
| Testing | Mirror app structure in tests/; test services independently |
| Requirements | Split by environment; pin all versions |
| Migrations | Never edit applied migrations; use squashmigrations when needed |
| Configuration | Use AppConfig with default_app_config pattern |
| Secrets | Always read from environment; never commit to git |
Conclusion
A well-structured Django project is not an accident. It's the result of deliberate, principled decisions made early — and then consistently enforced as the project grows.
The structure outlined here — split settings, an apps/ namespace, a service layer, a selectors layer, and a shared common/ module — isn't dogma. It's a battle-tested foundation used across dozens of production systems. It's specifically designed to remain navigable when the team doubles, the domain grows, and the deadline pressure climbs.
The most important takeaway isn't any specific file layout. It's the reasoning: separate concerns, keep each layer responsible for exactly one thing, isolate third-party dependencies, and make every component independently testable.
Start with this structure on your next Django project. When you feel the temptation to put business logic in a view "just this once," recall how it felt to read someone else's fat-view spaghetti — and resist.
The developers who inherit your code (including future you) will thank you.
Further Reading
- Django Documentation — Applications
- HackSoft Django Styleguide
- Two Scoops of Django — Audrey & Daniel Feldroy
- The Twelve-Factor App
- pytest-django Documentation
Written by a Python backend engineer building production Django systems. If this helped you, share it with your team.
Top comments (0)