This is my first technical blog post. More will follow on Django, architecture, and things I learned the hard way. Discussion is very welcome.
A few weeks ago, a senior Django developer (more experienced than me π ) joined a project I had been building solo. After reviewing the codebase, he sent me this message (translated and paraphrased from French):
"There are quite a few things that are not well finished. Some deficiencies. But there are also some very beautiful things that allowed me to move fast."
That last sentence is why I told myself: why not start writing, both to preserve what worked and to connect with others? So here I am. π
Properly structuring a project, especially a large one, is crucial. It's about making decisions early that let you, and others, navigate confidently, add features safely, and hand the project off without needing to be called back every five minutes.
Let's get into it. π
The Problem With Django's Default Layout
When you run django-admin startproject backend, you get this:
backend/
βββ backend/
β βββ settings.py
β βββ urls.py
β βββ wsgi.py
β βββ asgi.py
βββ manage.py
Then you add your first app:
python manage.py startapp myapp
And suddenly:
backend/
βββ myapp/
β βββ models.py
β βββ views.py
β βββ admin.py
β βββ tests.py
βββ backend/
β βββ settings.py
This works perfectly. For a tutorial. π¬
In a real production context, models.py quickly hits 1,000+ lines. views.py becomes a novel. settings.py mixes database config, email config, AWS config, etc.
The structure that scales is not the one you start with. It's the one you design from day one.
Let's fix that.
1. The Top-Level Split: apps/ and backend/
The first and most impactful decision: separate your Django apps from your project configuration.
my-project/
βββ backend/ β Django project config (settings, urls, celeryβ¦)
βββ apps/ β All your Django apps live here
βββ docs/ β Documentation
βββ scripts/ β Utility scripts
βββ tests/ β Test suite
βββ manage.py
βββ requirements.txt
Any developer opening this repo for the first time immediately understands the map:
-
backend/β framework config, don't touch without a reason -
apps/β this is where the features and business logics are implemented
Simple. Obvious. Zero ambiguity. That's the goal.
2. The backend/ Folder: Config That Breathes
Still with me? Good. π
The most common mistake inside backend/ is keeping a single monolithic settings.py. JWT, email, AWS, rate limiting: all in one file becomes a wall with no structure.
Split settings by domain:
backend/
βββ settings/
β βββ __init__.py β Loads sub-modules
β βββ base.py β Core Django config (installed apps, middleware, DBβ¦)
β βββ smtp.py β Email config only
β βββ configs/
β βββ storages.py β S3/media storage config
βββ urls.py β Root URL dispatcher
βββ celery.py β Celery app init (if using async tasks)
βββ asgi.py
βββ wsgi.py
# backend/settings/smtp.py: email concerns only
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = os.environ.get("EMAIL_HOST")
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
EMAIL_USE_TLS = True
# backend/settings/configs/storages.py: storage concerns only
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_BUCKET_NAME")
When a DevOps engineer needs to update email config, they go directly to smtp.py. They don't have to read 400 lines of unrelated settings. This is respect for the next developer's time. π
3. The apps/ Folder: One Domain, One App
Every feature domain gets its own Django app. No monolithic core/ app that holds everything.
apps/
βββ authentication/ β Registration, login, JWTβ¦
βββ transactions/ β Transactions, refundsβ¦
βββ users/ β User model, profilesβ¦
βββ notifications/ β Email, SMS, Push, WhatsAppβ¦
βββ marketing/ β Referrals, contact messagesβ¦
βββ settings/ β System and per-user settings
βββ utils/ β Shared code (base model, enums, errors, middleware)
Each app is a bounded context: it owns its models, its views, its serializers, its business logic. If tomorrow you extract notifications/ into a separate microservice, the limits are already drawn.
The rule I apply: if you can describe it in one domain noun, it's an app. Authentication, Transactions, Notifications. Not Stuff, not Core, not Api.
4. Inside an App: Every File Has a Job
Still following? π This is where most Django projects diverge from best practices.
The generated models.py / views.py monolith pattern doesn't scale. Here's what a well-structured app looks like instead:
apps/transactions/
βββ models/
β βββ __init__.py β Star-imports all model files
β βββ transactions.py
β βββ refunds.py
β βββ β¦
βββ serializers/
β βββ transactions.py
β βββ refunds.py
β βββ β¦
βββ views/
β βββ transactions.py
β βββ refunds.py
β βββ β¦
βββ services/
β βββ transaction_processor.py
β βββ refund_service.py
βββ admin/
β βββ __init__.py
β βββ transactions.py
βββ router.py
βββ urls.py
βββ tasks.py β Celery tasks (can be split too)
βββ apps.py
βββ tests.py
Split models into sub-files
The models/__init__.py re-exports everything via star imports:
# apps/transactions/models/__init__.py
from apps.transactions.models.transactions import *
from apps.transactions.models.refunds import *
From Django's perspective, nothing changes: apps.transactions.Transaction still works. From a developer's perspective, everything changes:
- Each model file stays focused and readable (< 300 lines)
- Git merge conflicts are rare and simple to resolve when they happen
- Searching "transactions" in your file list returns models, services, serializers, all at once
The services/ layer
Views should not contain business logic. This isn't a rule I invented. It's a principle that saves you every time you need to reuse logic in a task, a management command, or a test.
# β Business logic in the view β classic antipattern
class TransactionViewSet(ModelViewSet):
def create(self, request):
dest_user = User.objects.get(...)
if not dest_user.is_active:
raise ValidationError("User not available")
# ...50 more lines of business logic...
# β
The view delegates to a service
class TransactionViewSet(ModelViewSet):
def create(self, request):
serializer = CreateTransactionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
transaction = TransactionProcessor.create_transaction(
user=request.user,
**serializer.validated_data
)
return Response(TransactionSerializer(transaction).data)
TransactionProcessor.create_transaction() can now be called from a view, a task, a test, or a management command, without changing a single line. πͺ
5. The Base Model: Your Foundation for Every Table
Every model in this project inherits from one class. Just one.
# apps/utils/models/base_model.py
from uuid import uuid4
from django.db import models
from django_softdelete.models import SoftDeleteModel
class AbstractCommonBaseModel(SoftDeleteModel):
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
active = models.BooleanField(default=True)
timestamp = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
Every model then uses it:
from apps.utils.models.base_model import AbstractCommonBaseModel
class Transaction(AbstractCommonBaseModel):
# uuid, active, timestamp, updated β already there for free
status = models.CharField(...)
amount = models.DecimalField(...)
Pure DRY. You get for free, on every table:
| Field | Benefit |
|---|---|
uuid (PK) |
No sequential IDs leaking record counts; safe in URLs |
active |
Logical on/off switch |
timestamp |
Creation date, always available |
updated |
Last modification date, always available |
| Soft delete |
.delete() marks the row deleted, doesn't destroy it |
Soft delete, specifically, has become a habit over the years. You never permanently remove a sensitive record. Even if a user "deletes" something from their view, the row stays for audits, disputes, and reconciliation. π
Defining this in the base model means you can never forget it. The pattern is enforced at the class hierarchy level.
6. The utils/ App: Your Project's Internal Library
Every project accumulates shared code. The question is whether it lives scattered everywhere or in one dedicated place.
apps/utils/
βββ models/
β βββ base_model.py β AbstractCommonBaseModel
βββ xlib/ β "extended library" (your project's vocabulary)
β βββ enums/
β β βββ base.py β BaseEnum
β β βββ errors.py β All error codes
β β βββ transactions.py β TransactionStatusEnum, etc.
β β βββ users.py β UserStatusEnum, etc.
β βββ error_util.py β Centralized error resolution
β βββ exceptions.py β Custom domain exceptions
β βββ validators.py β Custom validators
βββ middleware/
β βββ β¦
βββ commons/
β βββ mixins.py β Reusable ViewSet mixins
βββ views/
βββ errors.py β Public error map endpoint
One pattern worth highlighting: the Enum system. Instead of scattering verbose choice tuples across model files:
# β The old way, repeated everywhere
status = models.CharField(choices=[('PENDING', 'Pending'), ('FAILED', 'Failed'), ...])
# β
One enum, reused everywhere
from apps.utils.xlib.enums import TransactionStatusEnum
status = models.CharField(
choices=TransactionStatusEnum.items(),
default=TransactionStatusEnum.PENDING.value,
)
One source of truth. No copy-paste drift. π―
7. URL Routing: Three Layers, Clear Contracts
Every app follows the same three-layer URL pattern. No exceptions.
Layer 1 (router.py): Register ViewSets
# apps/transactions/router.py
from rest_framework.routers import DefaultRouter
from apps.transactions.views import TransactionViewSet, RefundViewSet
router = DefaultRouter()
router.register(r'refunds', RefundViewSet, basename='refunds')
router.register(r'', TransactionViewSet, basename='transactions')
urls_patterns = router.urls
Layer 2 (urls.py): App URL contract (router URLs + any manual path() entries)
# apps/transactions/urls.py
from apps.transactions.router import urls_patterns
urlpatterns = urls_patterns
Layer 3 (backend/urls.py): Root dispatcher, versioned
urlpatterns = [
path("v1/auth/", include("apps.authentication.urls")),
path("v1/transactions/", include("apps.transactions.urls")),
path("v1/users/", include("apps.users.urls")),
# β¦
]
A new developer looking for "where does the transaction list endpoint come from" follows this path in under 60 seconds. No magic. No hunting. πΊοΈ
When you need /v2/, you add a second block without touching the first.
Bonus: scripts/ and docs/
Small things that signal a professional codebase:
scripts/
βββ rmpycaches.py β Clear all __pycache__ folders
βββ rmmigrationfiles.py β Remove migration files (for dev resets)
docs/
βββ modelings/ β (For example ) Use cases, class diagrams, sequence diagrams
The Real Impact
We're almost done, and this last part is the reason any of this matters. π
The senior developer who reviewed my codebase moved fast. He could navigate without asking me where things were. That's the metric that matters: not "is the code clever" but "can the next person read it?"
During development:
- You never wonder "where should I put this?". The structure answers.
- Features are isolated: adding
marketing/doesn't touchtransactions/ - The base model prevents forgotten patterns (timestamps, soft delete)
During code review:
- PRs stay small because changes live in one app's files
- Reviewers know exactly where to look
During onboarding:
- A new developer maps the codebase in 20 minutes by reading folder names
-
backend/urls.pyis the full table of contents for the API
During incidents:
- Bug in transaction creation? Check
services/transaction_processor.py, not a 1,000-lineviews.py
During refactoring:
- Extracting
notifications/into a microservice means moving one folder, not grepping scattered code
Takeaway Checklist
For your next Django project, or to refactor your current one:
- [ ] Name your config folder
backend/(orconfig/), not after your project - [ ] Put all apps under
apps/ - [ ] Split
settings.pyby domain:smtp.py,storages.py, etc. - [ ] Create
AbstractCommonBaseModelwith UUID PK, timestamps, and soft delete - [ ] Split
models.pyintomodels/<domain>.pyfiles, re-exported via__init__.py - [ ] Add a
services/layer: views call services, not raw ORM - [ ] Create
apps/utils/as your project's internal library - [ ] Use enums for model choices (one source of truth)
- [ ] Follow the three-layer URL pattern:
router.pyβurls.pyβbackend/urls.py - [ ] Add a
scripts/folder for repetitive dev commands - [ ] Write at least a minimal
docs/. Your future self will thank you.
What's Next
Next post: Standardized error handling in Django REST Framework, with i18n support and a React integration example.
Every API returns errors. The question is whether they're consistent, translatable, and useful to the frontend consuming them. I'll show you the ErrorEnum + ERROR_MAP pattern, how drf-standardized-errors shapes every response, and how to wire it up so a React app displays the right message in the right language, without any if/else chains.
If you spotted something I got wrong, or if you do it differently and it works better, comment below. I'm here to learn as much as to share. π€
Top comments (0)