Django Backend Foundations: Building Blocks - Apps, Models, and Views


Hi there. Welcome back to Django Backend Foundations! In Part 1, we established our Django development environment, created our first project, and understood the critical roles of settings.py
, manage.py
, and the ASGI/WSGI interfaces. Our foundational house is built – now it's time to furnish it with the essential building blocks.
In this second installment, we'll get into the heart of a Django project's architecture: Django apps, Models, and Views. You'll learn why Django projects are structured into reusable apps, how Models define your database schema and interact with data, and how Views handle incoming requests and prepare responses. We'll go in-depth into each of these components, ensuring you understand their purpose, how they work together, and how to implement them in your own project.
apps/
:
Apps are modular components — self-contained chunks of your project, each with its own models, views, templates, static files, URLs, etc., that help keep code clean and organized.
You can reuse them across projects. For example, you could package an app for user authentication or blogging and plug it into multiple projects. Django’s INSTALLED_APPS
setting controls which apps get loaded and integrated.
You can think of your Django “project” as the entire container for your site, which can have multiple apps inside. Each app is a mini Django project, a self-contained module focused on one responsibility.
For example:
users/
— everything about authentication and profilesblog/
— all logic related to blog posts and commentsorders/
— shopping cart, checkout, etc.chat/
- live chat, messages
Apps can have their own models.py
, views.py
, urls.py
, templates/
and static/
folders (you create these inside the app folder).
When you create an app using:
python manage.py startapp users
…you get a folder like this:
Why are Django Apps great?
Each app is like a mini Django project
It can be moved, reused, tested independently
You can publish it as a package on PyPI
Helps large teams divide up work cleanly
apps.py
: Django expects an apps.py
file in each app, which defines app config — not just a code folder but a registered component. This file defines a subclass of AppConfig
. Here’s the default that you get:
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'users'
This class tells Django:
- What your app is called (
name = 'users'
)
Developers typically match this to the route group for readability e.g. {{baseUrl}}/api/v1/users, {{baseUrl}}api/v1/orders.
It becomes useful when you:
Customize ready() to run logic at startup (e.g., connect signals)
Change app behavior with attributes (like
label
,verbose_name
, etc.)
Example of ready()
in Use:
# users/apps.py
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'users'
def ready(self):
import users.signals # connect signals when app is loaded
This is a common place to hook into app initialization, and it’s the closest equivalent to lifecycle hooks in other frameworks.
Signals
You may be wondering what signals are.
Django signals are a messaging system that allow decoupled components to get notified when certain events occur elsewhere in the application.
In plain terms, a signal lets one part of your app yell, “Hey! Something just happened!” and other parts can choose to react to that event — without being tightly coupled to it (which is always the goal. Brief explanation)
This is part of Django’s Observer pattern implementation.
You use signals when, let’s say:
A user is created, and you want to send them a welcome email
A profile should automatically be created when a user registers
You want to log changes when a model is updated
You could do all of this in the same view or serializer — but that’s messy and hard to maintain. Signals keep concerns separated.
A basic flow:
You define a signal handler (a function that runs when the signal is triggered)
You connect it to a signal (like
post_save
)Django fires the signal automatically when the event happens
Here are some common built-in signals
Django comes with many signals out of the box. The most commonly used are:
Signal | Description |
pre_save | Just before a model instance is saved |
post_save | Right after it’s saved |
pre_delete | Before an object is deleted |
post_delete | After deletion |
m2m_changed | When a ManyToMany field is modified |
request_started , request_finished | Around HTTP request lifecycle |
user_logged_in , user_logged_out , user_login_failed | Authentication events |
Here’s a basic “Create Profile After User Registers” example. If you’re a beginner, it’s okay if you do not completely understand what is going on. Comments will be added for you to follow along.
signals.py
inside theusers
app:# users/signals.py from django.db.models.signals import post_save from django.contrib.auth.models import User from django.dispatch import receiver from .models import Profile # I know... this doesn't exist yet @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): if created: Profile.objects.create(user=instance)
sender: the model class that sent the signal (
User
in this case)instance: the actual model instance that was saved (the new
User
object)created: a boolean:
True
if a new record was created,False
if updated**kwargs: extra keyword arguments that may be passed (varies by signal)
connect in
apps.py
i.e. import in in the ready function
Best Practices for Using Signals
Avoid business logic inside signals: Signals are great for side effects (sending emails, analytics, logging) but don’t put mission-critical logic in them (like charging a customer🌚)
Isolate signals in
signals.py
: As always, keeps things modular and testable which makes it easier to manage than stuffing them inmodels.py
Always import signals in
apps.py
: This ensures they’re registered when the app loads. Do it inready()
— not in__init__.py
or elsewhereUse
@receiver
which is cleaner and safer than manually connecting like this:post_save.connect(handler, sender=User)
Watch for performance bottlenecks: Don’t trigger expensive tasks in real-time; consider using Celery for async and long-running queued tasks
Only use signals when decoupling is needed
Avoid circular imports by importing only into
apps.py
. This delays the signal registration until all apps are fully loaded, avoiding circular imports.Don’t silently fail. If a signal is critical (e.g., audit logging), ensure it raises errors in dev/test.
Custom Signals
Sometimes Django’s built-in signals aren’t enough. Maybe you want to:
Fire a signal when someone uploads a CSV file
Signal that a user finished onboarding
Track when a payment was successful, without hard-wiring it into your views
This is where custom signals come in.
First, you define the signal by using Django’s Signal
class:
# users/signals.py
from django.dispatch import Signal
user_onboarded = Signal()
Then, you dispatch (send) the signal. This is the part of your app that triggers the signal:
# maybe in a view
from users.signals import user_onboarded
def complete_onboarding(request):
user = request.user
# ... whatever logic finishes onboarding ...
user_onboarded.send(sender=user.__class__, user=user)
sender
: by convention, you pass the class or module sending the signaluser
: custom data you're passing with the signal
Create the signal receiver
# users/receivers.py or users/signals.py
from django.dispatch import receiver
from .signals import user_onboarded
@receiver(user_onboarded)
def notify_user_onboarded(sender, **kwargs):
user = kwargs["user"]
print(f"{user.username} has completed onboarding.")
# You could also: send an email, trigger analytics, etc.
Hook it up in apps.py
. This part is compulsory
# users/apps.py
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'users'
def ready(self):
import users.receivers # or users.signals if receiver lives there
If you skip this step, Django won’t know to register the signal handler on startup.
models.py
:
This is where you define your data schema — aka, your database tables — using Python classes. A Django model is a Python class that represents a table in your database. Each attribute of the class becomes a column in the table, and each instance of the model represents a row.
All Django Models Inherit from the base class, models.Model
, which gives:
An
id
primary key (auto-added)An
.objects
manager, which is your primary interface for querying and manipulating data in the database.Methods like
.save()
,.delete()
, etc.
from django.db import models
class BlogPost(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
Example:
Django ships with a built-in User
model via django.contrib.auth.models.User
. But it’s common to create a Profile
model to store additional information about a user — things like a bio, profile image, or preferences.
# users/models.py
from django.db import models
from django.contrib.auth.models import User
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
location = models.CharField(max_length=100, blank=True)
profile_image = models.CharField(blank=True) # s3 link
birth_date = models.DateField(blank=True, null=True)
def __str__(self):
"""
method in a Django model that defines how the object is represented as a string —
especially in the admin interface, logs, shell, and dropdowns.
To keep name: return self.user.name
"""
return f"{self.user.username}'s profile"
OneToOneField
: creates a strict one-to-one relationship between User and Profile.TextField
,CharField
,DateField
: basic fields for storing text and dates.blank=True
: field is optional in forms.null=True
: field is optional in the database.
Migrations:
Once you've defined your model, Django needs to update the database schema.
Step 1: Create a migration file
python manage.py makemigrations # Scans your models, detects changes, and creates migration files. Kinda like a todo list for your app
This scans your models.py
files and generates migration scripts.
💡 This doesn’t touch the database yet — it just creates a plan for what needs to change.
Step 2: Apply the migration
python manage.py migrate
This creates the actual Profile
table in the database (along with any other pending migrations).
Creating a User and Profile
Once your models are migrated, you can interact with them using Django’s ORM.
We need a shell to run commands (We don’t have DRF setup yet, so no pretty GUI):
python manage.py shell # triggers the interactive console
>>> #command
This command starts a standard Python REPL, but importantly, it pre-loads your Django environment, making all your models and settings available. It is crucial for interacting with your database via the ORM directly.
from django.contrib.auth.models import User
from users.models import Profile
# Create user
user = User.objects.create_user(username='tito', password='secure123')
# Create associated profile
profile = Profile.objects.create(user=user, bio="Backend engineer", location="Lagos")
# Accessing related data
print(user.username) # 'tito'
print(profile.user.username) # 'tito'
Note: You are going to need to enter these lines one-by-one in the interactive shell. To quit: Ctrl+Z.
This uses Django’s built-in create_user
method, which:
Hashes the password correctly
Handles user defaults
Best Practice: Automatically Create Profile When User is Created
You don’t want to manually create a profile every time. Here’s how to automate it using a signal.
In users/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import Profile
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
else:
instance.profile.save()
Then connect the signal in apps.py
:
# users/apps.py
def ready(self):
import users.signals
This ensures that every time a user is created, a matching profile is also created.
Defining Your Fields: Data Types and Options
Each attribute you define in your model class becomes a column in your database table. Django provides a full set of field types to map various kinds of data.
# users/models.py
from django.db import models
from django.contrib.auth.models import User
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE) # one-to-one relationship
bio = models.TextField(blank=True, help_text="A short biography.")
location = models.CharField(max_length=100, blank=True, verbose_name="City/Town")
profile_image = models.URLField(blank=True, default='https://example.com/default.jpg') # stores a URL
birth_date = models.DateField(blank=True, null=True)
is_public = models.BooleanField(default=True)
followers_count = models.PositiveIntegerField(default=0) # only non-negative integers
def __str__(self):
return f"{self.user.username}'s profile"
Common field types and their arguments include the following:
CharField(max_length=...)
:For short strings (e.g., names, locations, titles).
max_length
is required and specifies the maximum length of the string, which translates to a database constraint. Limit is 255 characters.
TextField()
:- For large blocks of text (e.g., articles, bios). It doesn't require
max_length
.
- For large blocks of text (e.g., articles, bios). It doesn't require
IntegerField()
,SmallIntegerField()
,BigIntegerField()
,PositiveIntegerField()
,PositiveSmallIntegerField()
:- For storing whole numbers. The different types relate to the range of numbers they can store, impacting database storage efficiency.
PositiveIntegerField
only stores non-negative integers.
- For storing whole numbers. The different types relate to the range of numbers they can store, impacting database storage efficiency.
FloatField()
:- For floating-point numbers (e.g.,
3.14159
).
- For floating-point numbers (e.g.,
DecimalField(max_digits, decimal_places)
:For precise decimal numbers (e.g., currency).
max_digits
: Total number of digits allowed (both before and after the decimal point).decimal_places
: Number of digits to store after the decimal point.
BooleanField()
:- For true/false values. Automatically translates to a boolean type in your database.
DateField()
,DateTimeField()
:For dates (
YYYY-MM-DD
) and date-time (YYYY-MM-DD HH:MM:SS
) values respectively.auto_now_add=True
: Automatically sets the field to the current date/time when the object is first created. (Useful forcreated_at
).auto_now=True
: Automatically updates the field to the current date/time every time the object is saved. (Useful forupdated_at
).
EmailField()
:- A
CharField
that includes built-in email validation.
- A
URLField()
:- A
CharField
that includes built-in URL validation.
- A
FileField()
,ImageField()
:- Used for storing paths to files or images. Django doesn't store the file itself in the database; it stores a reference to where the file is stored (e.g., on your local filesystem, or cloud storage like S3). You'll need to configure storage backends for these.
UUIDField()
:- For storing Universally Unique Identifiers. Often used as a primary key instead of the default auto-incrementing integer.
Common Field Arguments (applicable to many field types):
blank=True
: allows the field to be left empty in forms and the Django Admin. This is a validation rule at the Django level. Ifblank=False
(default), the field is required.null=True
: allows the database column to storeNULL
values. This is a database-level constraint. Ifnull=False
(default), the field cannot beNULL
in the database.- An important thing to note is that for
CharField
andTextField
, avoidnull=True
. Useblank=True
anddefault=''
instead to prevent having bothNULL
and empty string values in your database, which can lead to inconsistencies.
- An important thing to note is that for
default
: sets a default value for the field if no value is provided during object creation. Can be a static value or a callable (e.g.,datetime.now
).unique=True
: ensures that this field's value must be unique across all records in the table. This adds a unique constraint at the database level. Great for emails.primary_key=True
: sets this field as the primary key for the model. Django automatically adds anid
AutoField
by default, so you only use this if you want a custom primary key.verbose_name
: A human-readable name for the field, often used in the Django Admin or forms. If not provided, Django infers it from the field's attribute name.help_text
: Provides a brief explanatory text for the field, often displayed in the Django Admin or forms.
Defining Relationships Between Models
Relational databases are powerful because they allow you to define relationships between different tables, connecting your data. Django's ORM provides specific field types for this.
OneToOneField(OtherModel, on_delete=...)
(One-to-One Relationship)This is a strict one-to-one relationship. One record in
OtherModel
relates to exactly one record in the current model, and vice-versa. Use when a model should have exactly one related object.The
Profile
model example is perfect for this, as it extends the built-inUser
model without modifying it directly. Another use would be aRestaurant
having oneAddress
.How does it work? A unique foreign key constraint is added to the table.
# users/models.py class Profile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) # accessing from User to Profile: user = User.objects.get(username='tito1') print(user.profile.bio) # can access the related Profile object # accessing from Profile to User: profile = Profile.objects.get(id=1) print(profile.user.email) # can access the related User object
Every
Profile
is linked to one and only oneUser
.ForeignKey(OtherModel, on_delete=...)
(One-to-Many Relationship)This is the most common type of relationship. It means one record in
OtherModel
can be related to many records in the current model. For example, oneUser
can have manyPost
s.Use when one model can be related to many of another.
class Post(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) title = models.CharField(max_length=100)
How does it work? A foreign key column is added to the table of the current model, storing the primary key of the related
OtherModel
record.The
on_delete
argument is crucial. This argument tells Django what to do when the referencedOtherModel
record is deleted.Most common usage is to add
models.CASCADE
, meaning when the referenced object is deleted, also delete the objects that have foreign keys pointing to it. (e.g., delete a User, delete all their Posts). A few otheron_delete
arguments include:models.PROTECT
: prevents deletion of the referenced object if there are any references to it. (e.g., cannot delete a Category if there are Products in it). RaisesProtectedError
.models.SET_NULL
: sets the foreign key toNULL
when the referenced object is deleted. Requiresnull=True
on theForeignKey
field. (e.g., delete a User, their Posts'user
field becomesNULL
).We also have
models.SET_DEFAULT
,models.DO
_NOTHING
, andmodels.RESTRICT
By default, Django creates a reverse relationship (e.g., user.post_set.all()
) on the related model (the "one" side), when you define a ForeignKey
on a model. By default, this manager is named:
{model_name_lowercase}_set
So, if your Post
model has a ForeignKey
to User
, then from a User
instance, you can access all associated Post
objects using user.post_set.all()
. You can also set related_name
which lets you customize this manager name (e.g., user.posts.all()
).
# assuming Post has a ForeignKey to User
post = Post.objects.get(id=1)
print(post.user.username) # can access the related User object
user = User.objects.get(username='alice')
print(user.post_set.all()) # can access all posts related to 'alice' (default related_name)
# Or if related_name='posts':
# print(user.posts.all())
You can also set related_name
which lets you customize this manager name (e.g., user.posts.all()
).
Here’s how you set it:
user = models.ForeignKey(User, on_delete=..., related_name='posts')
ManyToManyField(OtherModel)
(Many-to-Many Relationship)Here, records in one model can be related to multiple records in another model, and vice versa. For example, a
Student
can enroll in manyCourse
s, and aCourse
can have manyStudent
s.How it works? Django automatically creates an intermediate "through" table (associative/join table) in your database to manage these relationships. You don't usually need to define this table yourself unless you want to add extra data to the relationship (e.g.,
enrollment_date
for aStudentCourse
relationship).# example: articles/models.py class Article(models.Model): title = models.CharField(max_length=200) tags = models.ManyToManyField('Tag') # can use string if Tag is defined later class Tag(models.Model): name = models.CharField(max_length=50, unique=True) # in a Django shell: article = Article.objects.create(title="My Great Article", content="...") tag1 = Tag.objects.create(name="Python") tag2 = Tag.objects.create(name="Web Dev") # add tags to an article article.tags.add(tag1, tag2) # Add tags # article.tags.remove(tag1) # Remove a tag # article.tags.clear() # Remove all tags # access tags related to an article for tag in article.tags.all(): print(tag.name) # access articles related to a tag for article in tag1.article_set.all(): # default related_name print(article.title)
Model Methods: Adding Behavior to Your Data
Models are not just about data; they can also encapsulate logic related to that data.
__str__(self)
: We initialized this earlier in our Profile model. This special method defines the "string representation" of a model object. It's what Django uses when it needs to display an instance of your model as a string.It's critical for the Django Admin interface (to show meaningful names in lists and dropdowns), debugging in the shell, and displaying objects in logs. Without it, you'd just see
<Profile object (1)>
. In our earlier usage, we returnreturn f"{self.user.username}'s profile"
would display “Tito’s profile” on the first row on Django admin when we look at the Profile table.get_absolute_url(self)
: This is another common convention in Django models for returning the canonical URL for a specific object. The Django Admin uses it to provide a "View on site" link for objects, and you can use it in your templates to generate links to individual object pages.from django.urls import reverse class Post(models.Model): title = models.CharField(max_length=200) slug = models.SlugField(unique=True) # assumes a unique slug for URL # ... def get_absolute_url(self): return reverse('post_detail', args=[self.slug]) # 'post_detail' would be a URL name
save(self, *args, **kwargs)
: You can override this method to add custom logic that runs before or after an object is saved to the database.Custom Methods: You can add any other Python methods to your model classes to encapsulate business logic related to that model.
class Product(models.Model): name = models.CharField(max_length=100) price = models.DecimalField(max_digits=10, decimal_places=2) is_available = models.BooleanField(default=True) def get_discounted_price(self, discount_percentage): if discount_percentage > 0 and discount_percentage <= 100: return self.price * (1 - discount_percentage / 100) return self.price def mark_as_unavailable(self): self.is_available = False self.save()
How it’s used:
# in a view, template, or shell: product = Product.objects.get(id=1) if condition: product.mark_as_unavailable() # updates product is_available field
views.py
:
In Django, views are the core of your business logic. They're responsible for taking a web request, processing it (querying the database, applying logic), and returning a web response (HTML, JSON, etc.).
In the MTV (Model–Template–View) architecture:
Model handles data.
Template renders it.
View connects the two.
In views.py
, you define functions or classes that respond to specific routes. A view is essentially the request handler for a specific URL pattern. Its primary responsibilities include:
Receiving HTTP Requests: It gets an
HttpRequest
object from Django's URL dispatcher, containing all the details about the incoming request (URL, method, headers, user, body data, etc.).Interacting with Models (Data): It uses the Django ORM to query, create, update, or delete data in your database.
Executing Business Logic: It performs any necessary calculations, validations, or operations based on the request and data.
Preparing Context Data: It gathers all the necessary information and prepares it to be sent to a template for rendering.
Returning HTTP Responses: It must return an
HttpResponse
object (or a subclass) that contains the content (e.g., HTML, JSON, a redirect) to be sent back to the client (web browser, API client)
We have two major kinds of views in Django:
1. Function-Based Views (FBVs)
Simple Python functions. Great for simple endpoints. The blog_detail_view
views function defined in the urls section is an FBV.
from django.http import HttpResponse
def hello_world(request):
return HttpResponse("Hello, world!")
For JSON responses:
from django.http import JsonResponse
def user_info(request):
return JsonResponse({"name": "Tito", "role": "Backend Dev"})
2. Class-Based Views (CBVs)
These are Python classes that allow for cleaner, reusable logic, especially with inheritance.
from django.views import View
from django.http import JsonResponse
class HelloWorldView(View):
def get(self, request):
return JsonResponse({"message": "Hello, class-based world!"})
You use .get()
, .post()
, etc. to define behavior for HTTP methods.
3. DRF Views
You’ll often use this, once you install Django REST Framework. This will be discussed in the last part of this series.
Every view, regardless of whether it's a function or a class, adheres to a core structure:
It must accept at least one argument, conventionally named
request
, which is anHttpRequest
object.It must return an
HttpResponse
object.
# views.py
from django.http import HttpResponse
def my_simple_view(request):
# access request data if needed (e.g., request.GET, request.POST, request.user)
# perform some business logic
# interact with models (e.g., fetch data from the database)
# prepare data for response
# return an HttpResponse
return HttpResponse("This is a simple response!")
Conclusion
You now understand how the fundamental pieces of a Django application — its apps, models, and views — fit together to form a cohesive system. In this part of "Django Backend Foundations," you've learned:
The crucial role of Django apps in organizing your project into reusable modules.
How Models serve as the blueprint for your database and facilitate seamless data interaction.
The function of Views in handling HTTP requests and generating appropriate responses.
Up Next: In Part 3: Mastering Its Out-of-the-Box Tools, we’ll explore Django's built-in components. We'll explore the ORM, the admin interface, user authentication, templating, and URL routing in depth, equipping you with the tools to build functional web applications right away.
Please leave a like if this was helpful❤️. Comment also if you have questions.
Subscribe to my newsletter
Read articles from Tito Adeoye directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
