Django Backend Foundations: Your First Project and Core Structure

Tito AdeoyeTito Adeoye
14 min read

If you’ve been eyeing Django/DRF with a mix of curiosity and confusion, you’re in the right place.

This isn’t a crash course. It’s a foundations course — for beginners, yes, but also for anyone who wants to truly understand what’s going on under the hood when you spin up a Django backend. You’ll learn what each piece of the puzzle does and why it exists, not just how to wire it all together.

We’ll cover everything you need to build a real API: models, views, serializers, routers, permissions, and how it all fits together. If you’ve done a bit of Python, great. Or if you’ve played around with other frameworks like Adonis.js, I’ll occasionally draw parallels to help bridge the mental model — just enough to connect dots, not overwhelm.

Think of this as your warmup before diving into the deeper waters of project work. By the end, my goal is for you to feel confident not just using Django & DRF, but understanding it — so when something breaks (and something always breaks), you won’t be lost.

Let’s get into it.

What even is a Django project?

Django is a high-level Python framework that emphasizes:

  • Rapid development

  • Security out of the box, and

  • A clean and pragmatic design

It follows the MTV architecture (Model-Template-View), Django’s version of MVC. You define:

  • Models: the data structure

  • Templates: how data gets displayed (HTML or otherwise)

  • Views: the logic that connects everything

Django encourages a clear separation of concerns and a strong project structure, making it ideal for beginners and production systems.

You don't just create a Django project; you create a project (your app container) and apps (modular components inside it).

# install django
pip install django

# Start a Django project
django-admin startproject mysite
cd mysite

# Start an app inside the project
python manage.py startapp users

Project Layout & Core Files

Here are the key files and folders you get out of the box:

manage.py: CLI for interacting with your project. It’s a wrapper around Django’s django-admin tool that lets you run things like:

python manage.py runserver     # Start dev server
python manage.py makemigrations
python manage.py migrate       # Apply migrations
python manage.py createsuperuser

Think of it as your gateway to managing and evolving the project.

Project folder(same name as project; mysite): Contains the actual project settings and configuration.

__init__.py: Makes the folder a Python package. It’s Python’s Package Marker. This file doesn’t do anything by itself unless you explicitly put code in it.

It just tells Python: “This folder is a package. You can import from it.”

Without it, you couldn’t do things like:

from blog.models import Post

or

from users.views import login_view

It’s required for things like:

  • Importing settings from mysite/settings.py

  • Registering apps in INSTALLED_APPS

  • Import resolution across apps

You’ll see __init__.py in every Django app and the root project folder.

You can use it to initialize logic when the package loads, but 99% of the time, it’s just left empty.

settings.py: your project's global config (installed apps, DB config, middleware, templates, e.t.c.). Its where Django looks to know: what database to connect to, which apps are installed, how to find templates, where to serve static files from, what middleware to apply, what domains are allowed to access your app, and a ton of other stuff.

Common variables in settings.py include:

  • BASE_DIR: This defines the root directory of your Django project (usually the outermost folder). It’s used to build paths to other folders like templates/ or static/.

      BASE_DIR = Path(__file__).resolve().parent.parent
    
  • SECRET_KEY: This is used for cryptographic signing — like for cookies, password resets, and tokens. Never expose it in public. In production, you load this from an environment variable.

      SECRET_KEY = 'your-super-secret-key'
    
  • DEBUG:

      DEBUG = True  # set to False in production!!
    
  • When False: Django hides error pages and serves static files differently

  • When True: you see detailed error pages with very detailed traceback + metadata, hundreds of lines long, that you do not want out there.

  • ALLOWED_HOSTS:

      ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'yourdomain.com']
    

    This is a list of sites on which your Django app is accessible. Django uses this to prevent Host header attacks. If someone sends a request from an unknown domain, it’ll be rejected.

    The values can be complete domain names e.g. www.yourdomain.com, subdomain wildcards e.g .example.com, or a wildcard, *, to match anything.

    By default, it’s an empty list ([]). When DEBUG = True (development), if ALLOWED_HOSTS is empty, Django automatically validates against ['.localhost', '127.0.0.1', '[::1]']. This allows you to develop locally without needing to set it explicitly.

    When DEBUG=False, setting ALLOWED_HOSTS becomes compulsory, as Django will refuse to serve any requests and you respond with a 400 Bad Request error.

  • INSTALLED_APPS: This tells Django what apps to look for when resolving models, templates, or routes and which apps should be registered with migrations, the admin panel, etc. You must add every app you create here.

      INSTALLED_APPS = [
          'django.contrib.admin',
          'django.contrib.auth',
          ...,
          'users',           # your app
      ]
    
  • MIDDLEWARE:

      MIDDLEWARE = [
          'django.middleware.security.SecurityMiddleware',
          'django.contrib.sessions.middleware.SessionMiddleware',
          'django.middleware.common.CommonMiddleware',
          ...
      ]
    

    Middleware are functions that run before and after every request, that alter Django’s input or output. They handle things like: setting up the session, protecting against CSRF, modifying the response, etc.

    Django provides its own built-in middleware components e.g. AuthenticationMiddleware but you can write your own custom middleware too.

  • ROOT_URLCONF: This points to your root urls.py file — the entry point for all routing.

      ROOT_URLCONF = 'mysite.urls'
    
  • TEMPLATES

      TEMPLATES = [
          {
              'BACKEND': 'django.template.backends.django.DjangoTemplates', # built-in template backend
              'DIRS': [BASE_DIR / 'templates'],  # global templates folder
              'APP_DIRS': True,
              'OPTIONS': {
                  'context_processors': [
                      'django.template.context_processors.request',
                      'django.contrib.auth.context_processors.auth',
                      'django.contrib.messages.context_processors.messages',
                  ],
              },
          }
      ]
    

    This config tells Django where to look for .html files if you’re using the templating engine.

  • OPTIONS: These are extra parameters to pass to the template backend.

  • DIRS: list of folders with templates. Initial value => []

  • APP-DIRS: You can also keep per-app templates in app_name/templates/app_name/ and Django will find them when you set APP_DIRS to True . Initial value => True

      blog/
      ├── templates/
      │   └── blog/
      │       ├── post_list.html
      │       └── post_detail.html
    

    How Django Loads Templates (Behind the Scenes)

  1. You call render(request, "some_template.html").

  2. Django looks in the folders specified in DIRS.

  3. If it can’t find it, and APP_DIRS=True, Django looks in: app_name/templates/app_name/

  4. If still not found, raises TemplateDoesNotExist.

  • WSGI_APPLICATION & ASGI_APPLCATION

      WSGI_APPLICATION = 'mysite.wsgi.application'
      ASGI_APPLICATION = 'mysite.asgi.application'
    

    This is the Python import path to your WSGI app. Web servers like Gunicorn and Uvicorn use this to start the app.

  • DATABASES:

      DATABASES = {
          'default': {
              'ENGINE': 'django.db.backends.sqlite3',
              'NAME': BASE_DIR / 'db.sqlite3',
          }
      }
    

    You define the engine (e.g., SQLite, PostgreSQL) and connection credentials here. You can switch to PostgreSQL by updating this.

  • AUTH_PASSWORD_VALIDATORS: Used to enforce rules when users create or change passwords. These validators help make passwords more secure.

      AUTH_PASSWORD_VALIDATORS = [
          {
              # prevents passwords that are too similar to the user's info (like username, email).
              'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
          },
          {
              # default is 8 characters; you can set a custom length.
              'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
              'OPTIONS': {
                  # sets custom minimum length to 12
                  'min_length': 12,
              }
          },
          {
              # blocks passwords that are too common (like “password123”).
              'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
          },
          {
              # prevents passwords that are all numbers (e.g., 12345678).
              'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
          },
          # go crazy. create your own custom validator
          {
              'NAME': 'users.validators.CustomPasswordValidator', # you have to create a validators file with a CustomPasswordValidator class 
              'OPTIONS': {
                  'min_length': 10,
                  'max_length': 20,
              }
          },
      ]
    

    CustomPasswordValidator class should look something like this:

      # users/validators.py --> I
      import re
      from django.core.exceptions import ValidationError
      from django.utils.translation import gettext as _
    
      class CustomPasswordValidator:
          def __init__(self, min_length=8, max_length=20):
              self.min_length = min_length
              self.max_length = max_length
    
          def validate(self, password, user=None):
              if len(password) < self.min_length or len(password) > self.max_length:
                  raise ValidationError(
                      _(f"Password must be between {self.min_length} and {self.max_length} characters long."),
                      code='password_length',
                  )
    
              if not re.search(r'[A-Z]', password):
                  raise ValidationError(
                      _("Password must contain at least one uppercase letter."),
                      code='password_no_upper',
                  )
    

    How do we utilize this? In a real-world Django API setup, you usually check the password in your view (or serializer, if you're using Django REST Framework). We’ll get there soon.

For a comprehensive list of settings.py configurations, visit here.

Bonus: How to Override per Environment

You don’t want the same settings in dev and prod. Common pattern:

# settings.py
from .base import *
try:
    from .local import *
except ImportError:
    pass

Then, in your settings/, split your settings across:

  • base.py

  • local.py (for dev)

  • prod.py (for production)

Or you could have two settings files, one for local and production which have local defaults which can be overriden by env. vars. and a second one for your test environment.
There are multiple ways to define your settings variables, across your chosen environments, choose what works best for you.

urls.py: the app/root-level URL router — where you include app routes. You can think of it as Django’s map. It connects URLs (incoming HTTPS paths) to views (code that handles them).

There are two main types of urls.py:

  1. Root-level — in your project folder (e.g. mysite/urls.py)

  2. App-level — inside each app (e.g. blog/urls.py)

Root urls.py

This is the entry point. It's where you register global routes or delegate to app routes:

# mysite/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('blog/', include('blog.urls')),  # 👈 delegating to app (blog)
]

So, when someone visits /blog/, Django hands off routing to blog/urls.py.

App-level urls.py

You define the actual views for that app:

# blog/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.blog_list, name='blog_list'),
    path('<int:id>/', views.blog_detail, name='blog_detail'),
]

# in blog/views.py; here you can access the param value
def blog_detail_view(request, id): # id is the value stored in <int:id>
    ...

This setup allows each app to manage its own routes — much like a modular Router in Express or Route.group in AdonisJS.

You use angle brackets to capture URL route parameter values (also called path parameter) e.g. blog/21, where 21 is the parameter value. You can add converter types as well to capture certain parameter value types. If no converter type is provided, the string made available, is matched ie. blog/ss will direct you to the blog_detail view if the int converter type is not made available, and may break, depending on your view’s logic.

Other default path converters include str, slug, uuid, and path. You can also register custom path converters in your URLconf using register_converter().

Regular expressions may also be used to define URL patterns, with re_path

from django.urls import re_path
from . import views

urlpatterns = [
    re_path(r'^articles/(?P<year>[0-9]{4})/$', views.article_year),
]
  • ^articles/ — the URL must start with articles/

  • (?P<year>[0-9]{4}) — captures a 4-digit number as a named group year

  • $ — ensures the URL ends after the year

And in views.py,

# views.py
from django.http import HttpResponse

def article_year(request, year):
    return HttpResponse(f"Articles from year: {year}")

Match a slug:

re_path(r'^blog/(?P<slug>[-a-zA-Z0-9_]+)/$', views.blog_detail)

Match an ID:

re_path(r'^item/(?P<id>\d+)/$', views.item_detail)

Optional parameters (use with caution):

re_path(r'^archive(/(?P<year>[0-9]{4}))?/$', views.archive)

Here are some helpful notes to have:

  • Use re_path() only when path() (which uses simpler converters like <int:id>) isn't enough.

    💡
    If you don’t absolutely need it, don’t use it. Not every dev on your team is going to know how to work regular expressions. It’s ugly, hard to read and 95% of the time, is not needed so it should be utilized for only edge cases (Yes, I’m biased🫠)
  • Named groups ((?P<name>...)) are passed to the view as keyword arguments.

  • Avoid overly complex regex when possible—maintain readability.

Regex cheat sheet here.

You may also access query parameters (?key=value pairs at the end of a URL).

Example:

/search/?q=django&page=2 # q => key; django => value

Only the /search/ part is matched by your URL pattern.

# users/urls.py

from django.urls import path
from .views import search_view

urlpatterns = [
    # ...,
    path('search/', search_view),
]
# users/views.py
def search_view(request):
    query = request.GET.get('q', '') # django; fallback to an empty string if empty
    page = int(request.GET.get('page', 1)) # 2; type coercion + fallback to 1 if empty 

    # do something with the query params
    print(f"Query: {query}, Page: {page}") # Query: django, Page: 2

    return render(request, 'search.html', {'query': query, 'page': page})
  • request.GET pulls everything after the ?. It will always return values as strings

  • Use .get('key', default) to avoid None issues

  • You don’t use <str:q> or similar in the URL — that's for path parameters, not query params.

asgi.py and wsgi.py: Entry points for asynchronous and synchronous deployments.

WSGI (Web Server Gateway Interface_ is the standard Python interface between web servers and web apps. It is synchronous and designed for traditional request-response web apps.

When you're deploying your Django app, your Python code doesn't serve the app directly. Instead, it needs a web server (like Gunicorn or uWSGI) to talk to the outside world (browsers, APIs, etc.). But that server doesn’t know how to “speak Django” — it needs an interface, which is exactly what wsgi.py is - the bridge between your server and your Django code.

Inside wsgi.py, you’ll see something like this:

"""
WSGI config for mysite project.

It exposes the WSGI callable as a module-level variable named ``application``.

For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""

import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')

application = get_wsgi_application()

Here’s what this does:

  • Sets up Django’s settings

  • Creates a callable object named application that the WSGI server (Gunicorn or uWSGI) uses to send HTTP requests into Django.

  • This application is like an inbox that receives every HTTP request, runs it through Django (middleware → views → response), and sends it back to the client.

Let’s say you're deploying your app using Gunicorn, a popular Python WSGI server:

gunicorn mysite.wsgi:application

That line says:

“Hey Gunicorn, use the application object inside the mysite/wsgi.py file.”

💡
You can think of WSGI like a receptionist (web server) passing messages (HTTP requests) into an office (your Django app). The receptionist takes the message, waits for you to handle it and then sends the reply. One at a time (Synchronous).

You can check this out for more information on WSGI, uWSGI deployment and Gunicorn deployment.

To spin up your web server locally, you run this command:

python manage.py runserver

Behind the scenes, Django's runserver uses wsgi.py (or asgi.py if you're doing async) as the entry point to your app — just like in production — but it wraps it with a built-in development server. Which is why you get the warning at the end when you run it:

How it all connects:

[Browser] → HTTP → [Django Dev Server (runserver)] → [WSGIHandler] → [Your Django App(your views, urls, etc)]

ASGI (Asynchronous Server Gateway Interface) is the newer standard that supports asynchronous Python, like async def, await, long-lived WebSockets, background tasks, etc. It is required if your app needs real-time features like chat, streaming, push notifications etc.

As it supports asynchronous operations, it can take a lot of requests (calls) at once and handles each without waiting for one to finish.

Here’s what a basic asgi.py looks like:

import os
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
application = get_asgi_application()

Same idea: it's a callable object (application) but for ASGI servers like Uvicorn or Daphne.

You’d run:

uvicorn mysite.asgi:application

Now the server can send async requests through Django.

Let’s say you were building a live chat app,

  • Your message history, channels, and user profiles → classic sync views → use wsgi.py

  • But your real-time chat? Needs WebSockets — which means asgi.py and ASGI.

You’d install Django Channels to extend asgi.py with WebSocket handling.

import os
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from chat.routing import websocket_urlpatterns

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": URLRouter(websocket_urlpatterns),
})

Now your Django app can handle both HTTP and WebSocket traffic.

You choose your deployment strategy: traditional (wsgi.py) or async-capable (asgi.py). Many hosting platforms (Heroku, etc.) still default to WSGI unless you explicitly set up ASGI.

templates/ and static/: You create these folders yourself when needed. They're not included by default.

templates/

Used for storing HTML templates. Like we discussed earlier, you can create a global templates/ folder and link it in your settings.py, or each app can have its own.

# settings.py
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],
        ...
    },
]

static/

Used for static assets like CSS, JS, and images.

{% load static %}
<link rel="stylesheet" href="{% static 'css/styles.css' %}">

Again, either global or app-level.

Conclusion

In this opening part of the Django Backend Foundations series, you've learned:

  • What Django is at its core and its philosophy.

  • How to set up your first Django project from scratch.

  • The purpose of some key project files like settings.py, and manage.py and folders like templates and static.

  • The fundamental roles of ASGI and WSGI in serving your Django application.

Up Next: In Part 2: Building Blocks - Apps, Models, and Views, we'll start populating our newly created project. We'll explore the concept of Django "apps", the roles of Models (how Django interacts with your database), and understand how Views process requests and deliver responses. You'll begin to see how Django constructs the core logic of your application.

Please leave a like if this was helpful❤️. Comment also if you have questions.

0
Subscribe to my newsletter

Read articles from Tito Adeoye directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tito Adeoye
Tito Adeoye