No history yet

Professional Django Architecture

Beyond the Basics

You've built a few Django projects. You know how to create models, wire up views, and design templates. Now, it's time to bridge the gap between building tutorials and engineering professional, production-ready systems. The difference lies not in new features, but in structure, philosophy, and foresight.

A professional Django application is built for the long haul. It's scalable, maintainable, and easy for other developers to join. This means adopting patterns that separate concerns cleanly and manage configuration safely. We'll start by looking at a core philosophy for modern web applications.

The Twelve-Factor App Philosophy

When building applications that will be deployed to the cloud, a set of best practices called the methodology provides a robust guide. One of its most critical principles is to store configuration in the environment. This means no secret keys, database passwords, or API credentials should ever be hardcoded in your Python files.

Hardcoding secrets is a major security risk. It makes your code fragile, as you can't deploy it to a new environment without changing the code itself.

Instead, we use environment variables. A popular tool for managing these in local development is python-dotenv. This library loads key-value pairs from a .env file into the environment when your application starts.

Your .env file, which should never be committed to version control, looks like this:

# .env
# This file is NOT committed to git!

SECRET_KEY='your-secret-key-goes-here'
DEBUG=True
DATABASE_URL='postgres://user:password@host:port/dbname'

Then, in your settings.py, you can load and use these variables:

# settings.py

import os
from dotenv import load_dotenv

load_dotenv()  # Loads variables from .env

SECRET_KEY = os.environ.get('SECRET_KEY')
DEBUG = os.environ.get('DEBUG', 'False') == 'True'
# Further database configuration would use DATABASE_URL...

This practice keeps your secrets safe and your configuration portable. When you deploy, you'll set these same variables directly on your hosting platform, and your code works without any changes.

A Professional Project Structure

The default Django project layout places manage.py in the root directory alongside your project's configuration folder. This works fine for small projects, but it can get messy. A cleaner, more professional standard is the src/ layout.

In this layout, all your Python code lives inside the src directory. This isolates your application logic from project-level configuration files like .gitignore, Dockerfile, or requirements.txt.

Benefits of the src/ layout include:

  • Clarity: A clean separation between your source code and project metadata.
  • Installability: Your package can be installed in an editable mode more reliably, which helps with tooling.
  • No Path Conflicts: It prevents accidental imports of your package from the current working directory, forcing you to test against the installed version.

Modular Apps and the Custom User

Django's 'app' concept is powerful. A common mistake is to put too much unrelated logic into a single app, creating a 'God-app' that becomes difficult to maintain. A professional approach favors many small, focused, and reusable apps.

Think of your project as a house. You don't build one giant room. You build a kitchen, a bedroom, and a bathroom. Each has a clear purpose but connects to the others. In Django, you might have a users app for authentication, a products app for inventory, and an orders app for purchases.

This brings us to one of the most important rules of starting a new Django project: always start with a custom user model.

If you launch a project with the default django.contrib.auth.models.User and later realize you need to add a field like a date of birth or a profile picture, changing it is extremely difficult. It's a foundational change that's much easier to make at the beginning.

To create a custom user model, you create a dedicated users app, inherit from AbstractUser, and tell Django to use it in settings.py before you run your first migration.

# users/models.py
from django.contrib.auth.models import AbstractUser

class CustomUser(AbstractUser):
    # You can add new fields here later
    pass

# settings.py
AUTH_USER_MODEL = 'users.CustomUser'

This gives you full control and future flexibility over the most central model in your application.

Fat Models, Thin Views

A common Django adage is "Fat Models, Thin Views." This means business logic should live in the model layer, not the view layer. Views should be lean, responsible only for handling HTTP requests and responses, not for complex data manipulation.

Let's say we need to calculate the final price of an order, including taxes and discounts. The wrong way is to put this logic in the view:

# A 'thin' model and a 'fat' view (anti-pattern)

def order_detail_view(request, order_id):
    order = Order.objects.get(id=order_id)
    
    # Complex business logic in the view
    subtotal = order.calculate_subtotal()
    tax = subtotal * 0.08
    discount = 0
    if request.user.is_premium_member:
        discount = subtotal * 0.10
    final_price = subtotal + tax - discount

    return render(request, '...', {'price': final_price})

This logic is now trapped in this one view. If you need to calculate the final price somewhere else (like in an API or an admin action), you have to duplicate it.

The better approach is to move the logic into a method on the Order model.

# A 'fat' model and a 'thin' view (good pattern)

# orders/models.py
class Order(models.Model):
    # ... fields ...

    def calculate_final_price(self, user):
        subtotal = self.calculate_subtotal()
        tax = subtotal * 0.08
        discount = 0
        if user.is_premium_member:
            discount = subtotal * 0.10
        return subtotal + tax - discount

# orders/views.py
def order_detail_view(request, order_id):
    order = Order.objects.get(id=order_id)
    final_price = order.calculate_final_price(request.user)
    return render(request, '...', {'price': final_price})

This is cleaner and reusable. But what if the logic becomes even more complex, involving multiple models or external API calls? That's where a pattern comes in.

A service is a plain Python function or class that encapsulates a specific business operation. It acts as an intermediary, keeping your models focused on data and your views focused on HTTP.

For our order example, a service might look like this:

# orders/services.py

from .models import Order
from external_payments import api

def complete_order(user, order_id):
    """Handles all logic for completing an order."""
    order = Order.objects.get(id=order_id)
    
    # 1. Calculate final price
    final_price = order.calculate_final_price(user)

    # 2. Call external payment API
    api.charge(customer=user.stripe_id, amount=final_price)
    
    # 3. Update the order status
    order.status = 'PAID'
    order.save()

    return order

Now the view is incredibly simple:

# orders/views.py

from . import services

def complete_order_view(request, order_id):
    services.complete_order(user=request.user, order_id=order_id)
    # ... redirect to a success page

By adopting these professional structures and patterns, you move from just writing code to architecting robust, scalable, and maintainable applications. Your future self, and your future teammates, will thank you.

Time to check your understanding of these architectural patterns.

Quiz Questions 1/5

According to the Twelve-Factor App methodology, where should application configuration like secret keys and database passwords be stored?

Quiz Questions 2/5

What is a primary benefit of using the src/ layout for a Django project?

These principles form the foundation for building high-quality Django applications that can grow and adapt over time.