Python Django Project Structure
What Django Is
Django is a Python web framework for building backend servers. It handles HTTP requests and returns responses — typically JSON when used as a REST API. It never renders pages; in a modern stack, that is the frontend’s job (React, etc.). Django’s role is to receive a request, do work (validate data, read/write the database, apply business logic), and return data.
Top-Level Repository Structure
A Django repository typically looks like this:
my-project/
└── my_django_project/ ← everything lives here
├── manage.py ← Django's CLI tool
├── requirements.txt ← list of Python packages to install
├── db.sqlite3 ← local development database (auto-generated)
├── .env ← secret environment variables (never committed to git)
│
├── my_django_project/ ← project-level config (same name as parent, intentional)
│ ├── settings.py ← all configuration
│ ├── urls.py ← the main URL router
│ ├── wsgi.py ← production server entry point
│ └── asgi.py ← async server entry point
│
├── users/ ← a Django "app"
├── posts/ ← another app
├── chats/ ← another app
└── ...manage.py
The command-line entry point for everything Django-related:
python manage.py runserver # start the dev server
python manage.py makemigrations # generate a migration file from model changes
python manage.py migrate # apply migrations to the database
python manage.py createsuperuser # create an admin account
python manage.py test # run all testsrequirements.txt
Lists every external Python package this project depends on. Anyone joining the project runs:
pip install -r requirements.txtpip is Python's package manager — equivalent to npm for JavaScript.
settings.py
The master configuration file. Key sections:
INSTALLED_APPS— declares which Django apps are active in this projectDATABASES— database connection (SQLite locally, PostgreSQL in production)MIDDLEWARE— layers that process every request before it reaches a view (CSRF protection, session handling, etc.)CORS_ALLOWED_ORIGINS— which frontend URLs are allowed to call this backend- Environment-specific config like email API keys, file storage, etc.
The Central Concept: Django Apps
What an App Is
A Django project is the whole codebase. A Django app is one self-contained module within that project, responsible for one area of functionality.
A project is made up of multiple apps. Each app owns its own models, logic, and endpoints. For example:
Appusers owns user accounts, login, authentication.
Appposts owns feed posts, comments, reactions.
Appchats owns direct messages, chat rooms.
Appeventsowns event listings and RSVPs.
App notificationsowns in-app notification delivery.
Why Apps Exist
Apps enforce separation of concerns at the module level. Without them, every model, view, and URL would pile into the same files. As a project grows, apps let different developers or teams work on different areas independently.
Apps are also reusable in principle — a well-written notifications app could theoretically be dropped into another Django project.
Registering an App
An app does not activate automatically just because its folder exists. It must be declared in settings.py:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
# ...
"users",
"posts",
"chats",
]Django uses this list to know which apps to scan for models, migrations, admin configurations, and more.
The Fixed File Structure Inside Every App
Every Django app contains the same set of files. Their names and roles are a convention the whole ecosystem follows:
users/
├── models.py
├── serializers.py
├── views.py
├── urls.py
├── services.py
├── daos.py
├── types.py
├── admin.py
├── apps.py
├── tests/
└── migrations/models.py — Database Schema Blueprint
Defines what data looks like in the database. Each Python class maps to one database table. Each class attribute maps to one column.
class User(AbstractBaseUser):
email = models.EmailField(unique=True)
full_name = models.CharField(max_length=127, null=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)Important: models.py is only a definition — a blueprint. It does not run queries. It does not "do" anything at runtime. It tells Django the shape of the data so Django can generate the correct SQL tables and let you reference those columns in code.
The actual database reads and writes happen elsewhere (in daos.py, covered below).
migrations — Database Change History
Every time you modify models.py (add a field, rename a column, create a new model), you do not manually alter the database. Instead, Django generates a migration file that records the change:
python manage.py makemigrations # creates a new file in migrations/
python manage.py migrate # applies it to the actual databaseMigrations are committed to git, so the entire team’s databases stay in sync. The migrations/ folder is an ordered history of every structural change the database has ever gone through.
urls.py — Map API Endpoint to Python Function
urlpatterns maps URL paths to Python functions. It has nothing to do with rendering pages — that is the frontend's responsibility. It answers exactly one question: when this API endpoint is called, which Python function should run?
urlpatterns = [
path("auth/login/", login_user),
path("auth/logout/", logout_user),
path("auth/verify-email/", verify_email),
path("auth/reset-password/", reset_password),
]When a request arrives for POST /auth/login/, Django looks up this table, finds login_userfunction, and calls it.
This example uses plain urlpatterns for clarity. Django REST Framework also provides more advanced routing features such as routers and @action, which can make URL configuration more elegant and scalable. I covered those concepts in more detail in my another article: "Django REST Framework: Why Routers Are Elegant." (https://medium.com/@natsuko-dev-blog/writing-clean-python-core-concepts-behind-methods-errors-and-data-design-584c4578e47c)
The Two-Level URL Routing System
Django uses a two-level router. The project-level urls.py does not define the API endpoints directly. It delegates to each app's own urls.py using include():
# my_django_project/urls.py — the main switchboard
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("users.urls")), # delegate to users/urls.py
path("", include("posts.urls")), # delegate to posts/urls.py
path("", include("chats.urls")), # delegate to chats/urls.py
]When a request comes in for POST /auth/login/:
- Django reads the main
urls.py→ seesinclude("users.urls")→ opensusers/urls.py - Django reads
users/urls.py→ findspath("auth/login/", login_user)→ callslogin_user()
The include() call means: load all the URL definitions from that file and treat them as part of this project. The empty string "" prefix means the URLs from each app are mounted at the root level as-is, with no added prefix.
This separation keeps each app’s URL definitions/API endpoints self-contained. The users team only manages users/urls.py; the posts team only manages posts/urls.py. They never touch each other's files.
views.py — The HTTP Layer
Views are Python functions that receive an HTTP request and return an HTTP response. This is the only layer that knows about HTTP — it reads from request, and it returns JsonResponse or Response.
Get Natsuko’s stories in your inbox
Join Medium for free to get updates from this writer.
Views are also where serializers are typically used. A serializer acts as a translator between the HTTP world (JSON) and the Python world (Python objects and dictionaries).
JSON (HTTP world)
↕
Serializer
↕
Python objects
(application world)A view function typically does three things:
- Use a serializer to parse and validate incoming request data
- Call the appropriate service function to do the actual work
- Return a JSON response with the result
def create_post(request):
serializer = CreatePostSerializer(data=request.data)
if not serializer.is_valid():
return JsonResponse(serializer.errors, status=400)
post = PostServices.create_post(serializer.validated_data)
return JsonResponse(
{"message": "Post created", "id": post.id},
status=201,
)Views should contain minimal logic. Business rules belong in services.py.
services.py — Business Logic
Services contain the “thinking” layer — rules, decisions, and validation that go beyond simple data format checks. They are called by views and call DAOs in turn. They have no knowledge of HTTP (no request objects, no HTTP status codes).
class PostServices:
def create_post(data):
if not User.objects.filter(id=data["author_id"]).exists():
raise ValidationError("Author does not exist.")
if data["content"].count(" ") < 2:
raise ValidationError("Post is too short to be meaningful.")
return PostDao.create_post(data)daos.py — Database Access
DAO stands for Data Access Object. This layer is the only place in the codebase that runs actual database queries. It uses Django’s ORM to translate Python into SQL:
class PostDao:
def create_post(data):
return Post.objects.create(**data)
def get_post(post_id):
return Post.objects.get(pk=post_id)
def get_all_posts():
return Post.objects.all()Services call DAOs to fetch or persist data. DAOs call nothing — they only interact with models.py as a reference for what fields and tables exist.
serializers.py — Translating Between JSON and Python
Although serializers are usually invoked from the view layer, their job is distinct enough that Django REST Framework places them in their own file.
Think of a serializer as a translator between:
JSON (HTTP world)
↔
Python objects (application world)Serializers do two jobs:
1. Input: validate and convert incoming JSON
When the frontend sends JSON data, a serializer validates its structure and converts it into Python data that services can safely work with.
class CreatePostSerializer(serializers.Serializer):
title = serializers.CharField(required=True, max_length=200)
content = serializers.CharField(required=True)
tags = serializers.ListField(
child=serializers.CharField(),
required=False,
)After validation, the view can access:
serializer.validated_datawhich is simply a clean Python dictionary.
2. Output: convert Python objects into JSON
When a view needs to return data, a serializer converts Python objects into JSON and controls exactly which fields are exposed:
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "full_name", "email", "pronouns"]In this example, password is deliberately omitted, so it is never sent to the frontend.
Serializer Validation vs. Service Validation
Both layers validate, but they validate different things at different stages.
The rule:
- Serializer — “Is the data well-formed?” Structure, type, required fields, format constraints. No database access needed.
- Service — “Is the data valid for our application?” Business rules and domain logic that often require querying the database.
Example 1 — Creating a Comment
Serializer (format gate):
class CreateCommentSerializer(serializers.Serializer):
content = serializers.CharField(required=True, max_length=500)
post_id = serializers.IntegerField(required=True)Checks: is content present? is it under 500 characters? is post_id an integer? No database needed.
Service (business gate):
class CommentServices:
def create_comment(post_id, user_id, content):
post = PostDao.get_post(post_id)
if post is None:
raise ValidationError("Post does not exist.")
if post.comments_disabled:
raise ValidationError("Comments are turned off on this post.")
return CommentDao.create_comment(post_id, user_id, content)Checks: does that post (and a user) actually exist? are comments allowed on it? Both require database access and domain knowledge.
Example 2 — Booking an Event
Serializer (format gate):
class BookEventSerializer(serializers.Serializer):
event_id = serializers.IntegerField(required=True)
seats = serializers.IntegerField(min_value=1, max_value=10)Checks: is seats between 1 and 10? is event_id present and an integer?
Service (business gate):
class EventServices:
def book_event(event_id, user_id, seats):
event = EventDao.get_event(event_id)
if event.available_seats < seats:
raise ValidationError("Not enough seats available.")
if EventDao.user_already_booked(user_id, event_id):
raise ValidationError("You have already booked this event.")
return EventDao.create_booking(event_id, user_id, seats)Checks: are there enough seats left? has this user already booked? Both require querying the current database state.
Authentication, Authorization, and Validation Are Three Different Things
These three concepts are often confused because they all “block” a request. They are entirely separate concerns, answered at different points in the request lifecycle, and produce different HTTP status codes on failure.
ConceptQuestion it answersHTTP status on failureAuthenticationWho are you? Are you logged in?401 UnauthorizedAuthorizationAre you allowed to perform this action?403 ForbiddenValidationIs the data you sent correct?400 Bad Request
The HTTP status codes make the distinction concrete. A 403 means the data was perfectly fine — the user simply does not have permission. A 400 means the data itself was the problem, regardless of who sent it.
Where each one lives in the request lifecycle
Request arrives
│
▼
Authentication ← "Is this user logged in?" (session or token check)
│ not logged in → 401, stop
▼
Authorization ← "Is this user allowed to hit this endpoint?"
│ not allowed → 403, stop
▼
Serializer ← "Is the data well-formed?" (format and type checks)
│ bad format → 400, stop
▼
Service ← "Is the data valid for our business rules?"
│ rule violated → 400, stop
▼
DAO → DatabaseIn Django, authentication and authorization are handled by permission_classes on the view — completely separate from serializers and services:
@permission_classes([IsAuthenticated]) # ← authorization, not validation
def delete_post(request, post_id):
serializer = DeletePostSerializer(data=request.data) # ← validation starts here
if not serializer.is_valid():
return Response(serializer.errors, status=400)
# ...When authorization logic ends up inside a service
The line can feel blurry when an authorization check requires database state. For example:
class PostServices:
def delete_post(post_id, requesting_user_id):
post = PostDao.get_post(post_id)
if post.author_id != requesting_user_id:
raise PermissionDenied("You can only delete your own posts.")
PostDao.delete_post(post_id)This is still conceptually authorization (“are you allowed to do this?”), not validation (“is your data correct?”). But it lives in the service layer because it requires fetching the post from the database first — something a permission_classes decorator cannot do without knowing the post ID context. The response status remains 403, not 400.
The rule of thumb: if the check is about data shape → serializer. If it’s about business rules → service. If it’s about identity and permission → permission_classes on the view, or inside the service when a database lookup is required.
The Full Request-to-Response Flow
HTTP Request
│
▼
my_django_project/urls.py ← which app handles this URL?
│ include("posts.urls")
▼
posts/urls.py ← which function handles this endpoint?
│ path("posts/", create_post)
▼
posts/views.py ← parse request → validate via serializer → call service → return JSON
│ PostServices.create_post(data)
▼
posts/services.py ← apply business rules → call DAO
│ PostDao.create_post(data)
▼
posts/daos.py ← run the actual database query
│ Post.objects.create(...)
▼
posts/models.py ← blueprint only; defines what "Post" looks like
│
▼
Database
│
▼ (data flows back up through the layers)
posts/serializers.py ← convert model object → JSON dict
│
▼
HTTP Response ← { "message": "Post created", "id": 42 }Each layer’s contract
LayerKnows aboutCallsNever touchesviews.pyHTTP requests and responsesserializers.py, services.pyDatabase directlyservices.pyBusiness rulesdaos.pyHTTP (request, status codes)daos.pyDatabase queriesmodels.py (as schema reference)Business rulesmodels.pyTable and column definitionsNothing—serializers.pyJSON shape and input formatmodels.py (as schema reference)Services, DAOs
Keeping these boundaries clean means each layer can be tested, replaced, or modified without breaking the others.
Summary
A Django project is a collection of apps, each owning one domain of functionality. Every app follows the same file structure: models.py defines the data shape, daos.py queries the database, services.py holds business logic, views.py handles HTTP, serializers.py translates between Python and JSON and validates incoming data format, and urls.py maps endpoints to functions. The project-level urls.py delegates to each app's urls.py via include().
models.py is a blueprint only — it defines structure but never runs queries. All actual database interaction happens in daos.py. Serializers validate data format; services validate business rules. Authentication, authorization, and validation are three separate concerns that run in that order, produce different HTTP status codes, and live in different parts of the codebase.







