DEV Community

Cover image for Django Project Structure Best Practices: A Production-Ready Guide
Alanso Mathew
Alanso Mathew

Posted on

Django Project Structure Best Practices: A Production-Ready Guide

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

  1. Introduction
  2. Core Concepts
  3. Architecture Overview
  4. Step-by-Step Implementation
  5. Code Examples
  6. Performance and Scalability Tips
  7. Common Mistakes to Avoid
  8. Real-World Use Cases
  9. Best Practices Summary
  10. 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# requirements/production.txt
-r base.txt
gunicorn==22.0.0
django-storages[s3]==1.14.3
sentry-sdk[django]==1.44.1
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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


Written by a Python backend engineer building production Django systems. If this helped you, share it with your team.

Top comments (0)