EthicsComputerCourses
Join training +91 90532 10052

Structuring Large Django Projects

Django & Flask · Lesson 29 of 42 Advanced 9 min read Updated

A tutorial project fits in one app and one settings.py. A real product does not. After a year, the library system from this course has members, loans, fines, an API, reports and three developers working on it at once. If everything still lives in catalog/views.py, every change touches the same files, bugs hide in 400-line views, and nobody knows where a rule such as "a member may borrow at most three books" is enforced.

In this lesson you will reorganise the Jaipur City Library the way professional teams do: split settings into a package for each environment, divide the code into apps by business domain, move business rules into service functions, and decide when Django signals help and when they hurt.

The layout of a growing project

Here is the structure we are aiming for. Nothing in it is required by Django; it is a set of conventions that many teams settle on because they keep large codebases readable:

Text
jaipur-library/
    manage.py
    requirements.txt
    config/                 # project package: settings and root URLs only
        settings/
            __init__.py
            base.py         # shared by every environment
            dev.py          # your laptop
            test.py         # the test run
            prod.py         # the live server
        urls.py
        wsgi.py
        asgi.py
    accounts/               # custom User model, login, profiles
    catalog/                # authors, genres, books
    loans/                  # members, loans, fines
        models.py
        services.py         # functions that change data (business rules)
        selectors.py        # functions that read data for pages and APIs
        views.py            # thin: read input, call a service, return a response
        admin.py
        tests/
    templates/

Three ideas carry most of the weight. The config package holds only project-wide wiring. Each app owns one part of the business and its tables. Inside an app, code that changes data is separate from code that shows it.

Step by step: a settings package

One settings.py full of if DEBUG: checks becomes hard to trust. A settings package gives each environment its own small file that starts from a shared base.

  1. Create the folder config/settings/ with an empty __init__.py.
  2. Move config/settings.py to config/settings/base.py. The file is now one folder deeper, so add one more .parent to BASE_DIR.
  3. In base.py, keep everything common: INSTALLED_APPS, MIDDLEWARE, AUTH_USER_MODEL, time zone, and safe defaults such as DEBUG = False.
  4. Create dev.py, test.py and prod.py, each starting with from .base import *, and change only what differs.
  5. Point manage.py, wsgi.py and asgi.py at the right default with os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev"). On the server, set the variable to config.settings.prod.
Python
# config/settings/base.py (top of the file)
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent.parent   # one level deeper than before

SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-not-secret")
DEBUG = False
AUTH_USER_MODEL = "accounts.User"
TIME_ZONE = "Asia/Kolkata"
LOAN_DAYS = 14          # our own setting, read with settings.LOAN_DAYS
Python
# config/settings/dev.py
from .base import *  # noqa: F403

DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]


# config/settings/test.py
from .base import *  # noqa: F403

PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]


# config/settings/prod.py
import os

from .base import *  # noqa: F403

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]          # crash early if missing
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

diffsettings prints the settings that differ from Django's defaults. It is the quickest way to confirm which file is really loaded:

Terminal
python manage.py diffsettings | grep -E "^(DEBUG|SETTINGS_MODULE|LOAN_DAYS)"
python manage.py diffsettings --settings=config.settings.test | grep -E "^(PASSWORD_HASHERS|SETTINGS_MODULE)"
DJANGO_SETTINGS_MODULE=config.settings.prod python manage.py check
Output
DEBUG = True
LOAN_DAYS = 14  ###
SETTINGS_MODULE = 'config.settings.dev'  ###
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
SETTINGS_MODULE = 'config.settings.test'  ###
Traceback (most recent call last):
  ...
KeyError: 'DJANGO_SECRET_KEY'

### marks settings that Django does not define itself, such as our LOAN_DAYS. The last command shows the production file doing its job: without a secret key in the environment, the server refuses to start instead of running with a known key. (On Windows PowerShell, set the variable first with $env:DJANGO_SETTINGS_MODULE = "config.settings.prod".)

Apps by business domain

New developers often split apps by technical layer: a models app, an api app, a utils app. That spreads one feature over many folders. Split by business area instead, the way the librarians themselves talk: accounts, catalogue, loans. Some rules that keep domain apps healthy:

  • One owner per table. Only loans changes Loan rows. Other apps call a function in loans/services.py instead of writing to its models directly.
  • Refer across apps with a string. models.ForeignKey("catalog.Book", ...) avoids importing the other app's models at import time, which prevents circular imports.
  • Point at the user model through settings. Use settings.AUTH_USER_MODEL in foreign keys and get_user_model() in code. Start every project with the custom user model from Authentication & Permissions, before the first migrate.
  • Keep apps small but not tiny. An app with one model and no behaviour usually belongs inside a bigger domain, and an app with fifty models is probably several domains.

Business logic in service functions

Where should "borrow a book" live? Not in the view, because the API, the admin action and a management command all need it too. Not in Book.save(), because saving a book is not borrowing it. Put it in a plain function in loans/services.py:

Python
# loans/services.py
from datetime import timedelta

from django.conf import settings
from django.db import transaction
from django.db.models import F
from django.utils import timezone

from catalog.models import Book

from .models import Loan, Member


class NoCopiesLeft(Exception):
    pass


@transaction.atomic
def borrow_book(*, member: Member, book: Book) -> Loan:
    """Lend one copy of a book to a member. Raises NoCopiesLeft."""
    changed = Book.objects.filter(pk=book.pk, available__gt=0).update(
        available=F("available") - 1
    )
    if changed == 0:
        raise NoCopiesLeft(f"No copies of {book.title} left")
    today = timezone.localdate()
    return Loan.objects.create(
        book=book, member=member, borrowed_on=today,
        due_on=today + timedelta(days=settings.LOAN_DAYS),
    )

The * forces keyword arguments, so a call reads borrow_book(member=aarav, book=godaan) and can never mix up the order. The function raises a clear exception instead of returning an HTTP response, so any caller can decide what to show. Trying it in python manage.py shell with one copy of Godaan left:

Python
godaan = Book.objects.get(slug="godaan")
loan = borrow_book(member=aarav, book=godaan)
print(loan, loan.borrowed_on, loan.due_on)
try:
    borrow_book(member=meera, book=godaan)
except NoCopiesLeft as error:
    print("Refused:", error)
print(Book.objects.get(slug="godaan").available, Loan.objects.count())
Output
Godaan -> Aarav 2026-11-02 2026-11-16
Refused: No copies of Godaan left
0 1

The view now shrinks to "read input, call the service, choose a response". Reading functions go in selectors.py, for example overdue_loans(), which applies the right select_related() once so every page that lists overdue loans is fast. The Transactions & Race Conditions lesson explains why borrow_book() uses update() with a filter.

Signals vs services

Django sends signals at certain moments, such as post_save after a model is saved. A receiver is a function that runs when the signal fires. Receivers are connected in the app's ready() method:

Python
# loans/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Loan


@receiver(post_save, sender=Loan)
def loan_saved(sender, instance, created, **kwargs):
    if created:
        print(f"[signal] SMS to {instance.member.phone}: return by {instance.due_on}")


# loans/apps.py
class LoansConfig(AppConfig):
    name = "loans"

    def ready(self):
        from . import signals  # noqa: F401  (connects the receivers)

Signals look convenient, but watch which operations actually send them:

Output
1. create():
[signal] SMS to 9810000003: return by 2026-11-16
2. bulk_create():
3. update():
loans: 2

bulk_create() saved a loan and update() changed every loan, yet no SMS went out, because neither calls save(). That is documented behaviour, and it is the classic signal bug. Signals also hide control flow: someone reading borrow_book() cannot see that an SMS is sent.

Use a service function whenA signal is reasonable when
The action is part of your own business process (borrow, pay, cancel)You must react to a model you do not own, such as a third-party app's model
Order matters, or failures must stop the actionThe reaction is optional and independent, such as clearing a cache
You want the behaviour visible and easy to testSeveral unrelated apps need to hear about one event

For the library, the SMS belongs in borrow_book(), ideally queued with transaction.on_commit() so it is sent only if the loan really saved.

Common mistakes

  • Forgetting the extra .parent after moving settings. BASE_DIR then points inside config/, Django creates a new empty db.sqlite3 there, and pages fail with "no such table".
  • Secrets in base.py. Every environment, including a leaked laptop copy, then has them. Read secrets from the environment in prod.py.
  • Business rules inside views or save(). They get copied into the API and the admin and drift apart. Put them in a service.
  • Signals for core logic. They are skipped by update() and bulk_create(), and they make the flow hard to follow.
  • Changing to a custom user model mid-project. It needs painful migrations; do it on day one.

Key points

  • Split settings into base.py plus one file per environment, and choose one with DJANGO_SETTINGS_MODULE; check with diffsettings.
  • Organise apps by business domain, refer across apps with strings like "catalog.Book", and let each app own its tables.
  • Put actions that change data in service functions with keyword-only arguments and clear exceptions; keep views thin.
  • Put reusable read queries in selector functions so query optimisation happens in one place.
  • Prefer explicit service calls to signals; signals do not fire for update() or bulk_create().

Exercise

Add a return_book(*, loan) service to loans/services.py. It must set returned_on to today, add one to the book's available count with an F() expression, and raise a LoanAlreadyReturned exception if the loan was already returned. Wrap it in a transaction and try it twice on the same loan in the shell.

Show solution
Python
class LoanAlreadyReturned(Exception):
    pass


@transaction.atomic
def return_book(*, loan: Loan) -> Loan:
    if loan.returned_on is not None:
        raise LoanAlreadyReturned(f"{loan} was returned on {loan.returned_on}")
    loan.returned_on = timezone.localdate()
    loan.save(update_fields=["returned_on"])
    Book.objects.filter(pk=loan.book_id).update(available=F("available") + 1)
    return loan
Output
>>> print(repr(return_book(loan=loan).returned_on))
datetime.date(2026, 11, 2)
>>> return_book(loan=loan)
Traceback (most recent call last):
  ...
loans.services.LoanAlreadyReturned: Godaan -> Aarav was returned on 2026-11-02

update_fields saves only the changed column, and F("available") + 1 lets the database do the addition. Because both writes are inside one transaction, a crash between them cannot leave a returned loan with the copy still missing.

Quick Quiz

1. Your project has config/settings/base.py, dev.py and prod.py. How does the live server choose prod.py?

2. Which way of splitting a large project into apps usually works best?

3. A post_save receiver sends an SMS for each new loan. Which call creates a loan without sending the SMS?

4. Why does borrow_book() use def borrow_book(*, member, book)?

Learn Django & Flask with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call