All articles
Январь 10, 2026 · 4 min read

How to Structure a Django Project for Long-Term Maintainability

Battle-tested Django project structure for production applications. App organization, settings management, and patterns that scale from MVP to enterprise.

DjangoPythonArchitectureBest Practices
By Kirill Strelnikov — Freelance Python/Django Developer, Barcelona

The Goal: Code That Survives Growth

Most Django projects start clean and become unmaintainable within 6 months. I have refactored enough legacy Django codebases to know what patterns survive and which ones don't. Here is the structure I use for every production project.

The Directory Structure

project/
+-- config/                  # Project configuration
|   +-- settings/
|   |   +-- base.py          # Shared settings
|   |   +-- development.py   # Dev overrides
|   |   +-- production.py    # Production settings
|   |   +-- test.py          # Test settings
|   +-- urls.py
|   +-- wsgi.py
+-- apps/
|   +-- users/               # User management
|   |   +-- models.py
|   |   +-- services.py      # Business logic
|   |   +-- selectors.py     # Query logic
|   |   +-- views.py         # HTTP layer only
|   |   +-- serializers.py
|   |   +-- tests/
|   +-- products/
|   +-- orders/
+-- common/                  # Shared utilities
|   +-- models.py            # Base models
|   +-- pagination.py
+-- requirements/
|   +-- base.txt
|   +-- dev.txt
|   +-- prod.txt
+-- manage.py

Settings Split

Never use a single settings.py for all environments. Split it:

# config/settings/base.py
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
INSTALLED_APPS = [
    'django.contrib.admin',
    # ...
    'apps.users',
    'apps.products',
]

# config/settings/production.py
from .base import *

DEBUG = False
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ['DB_NAME'],
        'HOST': os.environ['DB_HOST'],
    }
}
SECURE_HSTS_SECONDS = 31536000

The Service Layer Pattern

This is the most important pattern. Keep your views thin — they handle HTTP. Business logic lives in services:

# apps/orders/services.py
from django.db import transaction

def create_order(*, customer, items: list[dict]) -> Order:
    with transaction.atomic():
        order = Order.objects.create(customer=customer, status='pending')

        total = 0
        for item_data in items:
            product = get_product_by_id(item_data['product_id'])
            if product.stock < item_data['quantity']:
                raise ValueError(f"{product.name} out of stock")

            OrderItem.objects.create(
                order=order,
                product=product,
                quantity=item_data['quantity'],
                price=product.price
            )
            total += product.price * item_data['quantity']
            product.stock -= item_data['quantity']
            product.save()

        order.total = total
        order.save()
    return order

Selectors for Query Logic

Separate read operations into selectors. This makes queries reusable and testable:

# apps/orders/selectors.py
from django.db.models import QuerySet, Sum

def get_orders_for_customer(*, customer_id: int, status: str = None) -> QuerySet:
    qs = Order.objects.filter(customer_id=customer_id)
    if status:
        qs = qs.filter(status=status)
    return qs.select_related('customer').prefetch_related('items')

def get_monthly_revenue(*, year: int, month: int) -> dict:
    return Order.objects.filter(
        created_at__year=year,
        created_at__month=month,
        status='completed'
    ).aggregate(total=Sum('total'))

Views: HTTP Layer Only

class OrderCreateView(APIView):
    def post(self, request):
        serializer = OrderCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        order = create_order(
            customer=request.user,
            items=serializer.validated_data['items']
        )
        return Response(OrderSerializer(order).data, status=201)

Testing Strategy

With this structure, testing is straightforward:

class CreateOrderTest(TestCase):
    def setUp(self):
        self.customer = create_test_user()
        self.product = create_test_product(stock=10, price=25.00)

    def test_creates_order_with_items(self):
        order = create_order(
            customer=self.customer,
            items=[{'product_id': self.product.id, 'quantity': 2}]
        )
        assert order.total == 50.00
        assert order.items.count() == 1

    def test_rejects_out_of_stock(self):
        with self.assertRaises(ValueError):
            create_order(
                customer=self.customer,
                items=[{'product_id': self.product.id, 'quantity': 999}]
            )

Key Principles

This structure has served me well across SaaS platforms, e-commerce systems, and client projects. It scales from solo developer to team without major refactoring.

Need help building something similar? I am a freelance Python/Django developer based in Barcelona specializing in AI integrations, SaaS platforms, and business automation. Free initial consultation.

Get in touch

Telegram: @KirBcn · Email: [email protected]