Structuring Large Django Projects
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:
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.
- Create the folder
config/settings/with an empty__init__.py. - Move
config/settings.pytoconfig/settings/base.py. The file is now one folder deeper, so add one more.parenttoBASE_DIR. - In
base.py, keep everything common:INSTALLED_APPS,MIDDLEWARE,AUTH_USER_MODEL, time zone, and safe defaults such asDEBUG = False. - Create
dev.py,test.pyandprod.py, each starting withfrom .base import *, and change only what differs. - Point
manage.py,wsgi.pyandasgi.pyat the right default withos.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev"). On the server, set the variable toconfig.settings.prod.
# 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# 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 = Truediffsettings prints the settings that differ from Django's defaults. It is the quickest way to confirm which file is really loaded:
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 checkDEBUG = 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
loanschangesLoanrows. Other apps call a function inloans/services.pyinstead 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_MODELin foreign keys andget_user_model()in code. Start every project with the custom user model from Authentication & Permissions, before the firstmigrate. - 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:
# 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:
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())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:
# 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:
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 when | A 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 action | The reaction is optional and independent, such as clearing a cache |
| You want the behaviour visible and easy to test | Several 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
.parentafter moving settings.BASE_DIRthen points insideconfig/, Django creates a new emptydb.sqlite3there, 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 inprod.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()andbulk_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.pyplus one file per environment, and choose one withDJANGO_SETTINGS_MODULE; check withdiffsettings. - 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()orbulk_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
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>>> 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?
manage.py, wsgi.py and asgi.py only set a default; the environment variable overrides it.
2. Which way of splitting a large project into apps usually works best?
Domain apps keep each feature's models, rules and views together.
3. A post_save receiver sends an SMS for each new loan. Which call creates a loan without sending the SMS?
bulk_create() does not call save(), so pre_save and post_save are not sent.
4. Why does borrow_book() use def borrow_book(*, member, book)?
Arguments after * are keyword-only, which makes calls self-explaining.
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
