← Back to DevBytes

Django Architecture: Design Patterns and Project Structure

Understanding Django Architecture

Django is a high‑level Python web framework that encourages rapid development and clean, pragmatic design. At its heart lies a set of carefully chosen design patterns and a recommended project structure that, when followed, lead to applications that are easy to build, test, and maintain. This tutorial explores Django’s architectural foundations, the design patterns woven into its core, and the best practices for organising your codebase so that it scales gracefully from a small prototype to a large, team‑maintained system.

What is Django Architecture?

Django’s architecture is built around the Model‑View‑Template (MVT) pattern, a variant of the classic Model‑View‑Controller (MVC) pattern tailored for the web. In Django’s interpretation:

A typical request flows like this:

Browser Request → URL Dispatcher → View → Model (if needed) → Template → HTTP Response

This clear separation of concerns means that a designer can work on templates without touching Python code, a backend developer can adjust models and views independently, and database migrations are handled in a structured way.

Why Architecture and Design Patterns Matter

Adopting a deliberate architecture from the beginning prevents the “big ball of mud” anti‑pattern that often plagues quickly‑grown projects. Benefits include:

Django itself enforces some of these patterns (e.g. you must place models in models.py by default), but it also leaves room for higher‑level patterns like the Service Layer and Repository, which we’ll cover below.

Core Design Patterns in Django

Model‑View‑Template (MVT)

This is the framework’s backbone. Here’s how the pieces fit together in a typical blog app:

models.py – Defines the data structure:

from django.db import models
from django.utils import timezone

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    published_at = models.DateTimeField(default=timezone.now)
    
    def __str__(self):
        return self.title

views.py – Contains the logic to retrieve and process data:

from django.shortcuts import render
from .models import Post

def post_list(request):
    posts = Post.objects.all().order_by('-published_at')
    return render(request, 'blog/post_list.html', {'posts': posts})

urls.py – Wires URLs to views:

from django.urls import path
from . import views

urlpatterns = [
    path('', views.post_list, name='post_list'),
]

Template (blog/templates/blog/post_list.html):

<h1>Latest Posts</h1>
<ul>
  {% for post in posts %}
    <li><strong>{{ post.title }}</strong> – {{ post.published_at|date:"F j, Y" }}</li>
  {% empty %}
    <li>No posts yet.</li>
  {% endfor %}
</ul>

This pattern keeps data access, business logic, and presentation clearly separated.

Repository Pattern with Managers

Django’s ORM already abstracts the database, but you can formalise data access further by using custom Managers and QuerySets. This acts as a lightweight Repository pattern, keeping complex queries out of views.

class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(status='published')

class Post(models.Model):
    # ... fields
    status = models.CharField(max_length=10, default='draft')
    
    objects = models.Manager()           # default manager
    published = PublishedManager()       # custom manager

Now in views you can write:

def post_list(request):
    posts = Post.published.all().order_by('-published_at')
    # ...

This encapsulates the “published” filter logic inside the model layer, making views cleaner and easier to test.

Service Layer Pattern

In larger applications, views can become bloated with business logic, API calls, and orchestration. Introducing a Service Layer keeps views thin and focused on HTTP concerns.

Create a services.py module inside your app:

# blog/services.py
from .models import Post
from .exceptions import PostNotFoundError

def get_latest_posts(limit=10):
    return Post.published.all().order_by('-published_at')[:limit]

def publish_post(post_id):
    try:
        post = Post.objects.get(pk=post_id)
    except Post.DoesNotExist:
        raise PostNotFoundError(f"Post {post_id} does not exist")
    post.status = 'published'
    post.save()
    return post

The view then delegates to the service:

from django.shortcuts import render
from .services import get_latest_posts

def post_list(request):
    posts = get_latest_posts(limit=20)
    return render(request, 'blog/post_list.html', {'posts': posts})

This makes business logic reusable across views, management commands, or even REST API endpoints, and simplifies testing by removing the HTTP layer dependency.

Decorator Pattern (Middleware and View Decorators)

Django uses decorators extensively to wrap views with additional behaviour. The built‑in login_required decorator is a classic example:

from django.contrib.auth.decorators import login_required

@login_required
def dashboard(request):
    # only authenticated users reach here
    return render(request, 'dashboard.html')

You can write custom decorators for permissions, logging, or throttling. Middleware applies cross‑cutting concerns globally (e.g. authentication checks, security headers). Together they implement the Decorator pattern, allowing you to layer functionality without modifying the view code itself.

Project Structure Best Practices

Django projects start with a default layout from startproject, but real‑world applications benefit from a more organised approach. Below is a recommended structure that scales well:

project_root/
├── config/                  # project configuration (replaces default project folder)
│   ├── __init__.py
│   ├── settings/
│   │   ├── base.py          # shared settings
│   │   ├── dev.py           # development overrides
│   │   └── prod.py          # production overrides
│   ├── urls.py              # root URLconf
│   ├── wsgi.py
│   └── asgi.py
├── apps/                    # custom applications (alternative: top‑level per‑app folders)
│   ├── blog/
│   │   ├── migrations/
│   │   ├── templates/blog/
│   │   ├── services.py
│   │   ├── models.py
│   │   ├── views.py
│   │   └── urls.py
│   └── accounts/
├── static/                  # global static files
├── media/                   # user‑uploaded files (development only)
├── requirements/
│   ├── base.txt
│   ├── dev.txt
│   └── prod.txt
├── manage.py
└── .env                     # environment variables (never commit)

Key decisions:

Multi‑environment Settings

Create a settings package. Example config/settings/base.py:

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent.parent

SECRET_KEY = os.environ.get('SECRET_KEY', 'fallback-for-dev-only')
DEBUG = False
ALLOWED_HOSTS = []

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    # ...
    'apps.blog',
]

MIDDLEWARE = [
    # ...
]

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_NAME'),
        # ...
    }
}

config/settings/dev.py:

from .base import *

DEBUG = True
ALLOWED_HOSTS = ['*']

DATABASES['default'].update({
    'ENGINE': 'django.db.backends.sqlite3',
    'NAME': BASE_DIR / 'db.sqlite3',
})

config/settings/prod.py:

from .base import *

DEBUG = False
ALLOWED_HOSTS = ['yourdomain.com']

# Security settings
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True

Then set DJANGO_SETTINGS_MODULE=config.settings.dev (or prod) in your environment.

App Organization

Inside each app, maintain a consistent structure. A well‑organized blog app:

blog/
├── __init__.py
├── admin.py
├── apps.py
├── exceptions.py        # custom exceptions
├── models.py
├── managers.py          # custom managers
├── services.py          # business logic
├── urls.py
├── views.py
├── tests/
│   ├── test_models.py
│   ├── test_services.py
│   └── test_views.py
├── templates/blog/
│   ├── base.html
│   ├── post_list.html
│   └── post_detail.html
└── static/blog/
    ├── css/
    └── js/

Grouping tests in a tests/ package (instead of a single tests.py) keeps them manageable as the app grows.

Static and Media Files

Configure global static directories in settings:

STATIC_URL = '/static/'
STATICFILES_DIRS = [BASE_DIR / 'static']   # for development
STATIC_ROOT = BASE_DIR / 'staticfiles'     # collectstatic destination

MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

During development, Django serves static files automatically when DEBUG=True. For production, run python manage.py collectstatic and serve them from your web server or CDN.

How to Implement a Robust Structure

Let’s walk through bootstrapping a new Django project with the recommended architecture step by step:

1. Create the project skeleton

django-admin startproject config .

This creates a config/ folder and manage.py in the current directory. Rename the inner config/settings.py to a package as described above.

2. Create the apps folder and start your first app

mkdir apps
python manage.py startapp blog apps/blog

3. Adjust config/settings/base.py

INSTALLED_APPS = [
    # Django apps...
    'apps.blog',
]

4. Define models inside apps/blog/models.py and run migrations.

5. Create a service layer for business logic and a custom manager for query encapsulation.

6. Wire views and URLs, keeping views thin.

7. Organize templates and static files in the app directories (or a global folder if preferred).

From this point, adding new apps follows the same pattern, and the project remains tidy and predictable.

Common Pitfalls and Anti‑patterns

Conclusion

Django’s MVT architecture provides a solid foundation for web development, but true maintainability and scalability come from the higher‑level design patterns and project organisation you layer on top. By embracing a Service Layer, encapsulating queries with custom Managers, splitting settings per environment, and adopting a clean project structure, you transform Django’s sensible defaults into a powerhouse that can handle real‑world complexity. The patterns described here are not rigid rules—they are proven conventions that keep your codebase navigable, testable, and a pleasure to work with as your application evolves. Start with these principles, and you’ll spend less time fighting the framework and more time delivering features.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles