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

Tito AdeoyeTito Adeoye
19 min read

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 profiles

  • blog/ — all logic related to blog posts and comments

  • orders/ — 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:

  1. You define a signal handler (a function that runs when the signal is triggered)

  2. You connect it to a signal (like post_save)

  3. 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:

SignalDescription
pre_saveJust before a model instance is saved
post_saveRight after it’s saved
pre_deleteBefore an object is deleted
post_deleteAfter deletion
m2m_changedWhen a ManyToMany field is modified
request_started, request_finishedAround HTTP request lifecycle
user_logged_in, user_logged_out, user_login_failedAuthentication 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 the users 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

  1. 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🌚)

  2. Isolate signals in signals.py: As always, keeps things modular and testable which makes it easier to manage than stuffing them in models.py

  3. Always import signals in apps.py: This ensures they’re registered when the app loads. Do it in ready() — not in __init__.py or elsewhere

  4. Use @receiver which is cleaner and safer than manually connecting like this:

     post_save.connect(handler, sender=User)
    
  5. Watch for performance bottlenecks: Don’t trigger expensive tasks in real-time; consider using Celery for async and long-running queued tasks

  6. Only use signals when decoupling is needed

  7. Avoid circular imports by importing only into apps.py. This delays the signal registration until all apps are fully loaded, avoiding circular imports.

  8. 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 signal

  • user: 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.
  • 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.
  • FloatField():

    • For floating-point numbers (e.g., 3.14159).
  • 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 for created_at).

    • auto_now=True: Automatically updates the field to the current date/time every time the object is saved. (Useful for updated_at).

  • EmailField():

    • A CharField that includes built-in email validation.
  • URLField():

    • A CharField that includes built-in URL validation.
  • 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. If blank=False (default), the field is required.

  • null=True: allows the database column to store NULL values. This is a database-level constraint. If null=False (default), the field cannot be NULL in the database.

    • An important thing to note is that for CharField and TextField, avoid null=True. Use blank=True and default='' instead to prevent having both NULL and empty string values in your database, which can lead to inconsistencies.
  • 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 an id 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-in User model without modifying it directly. Another use would be a Restaurant having one Address.

    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 one User.

  • 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, one User can have many Posts.

    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 referenced OtherModel 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 other on_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). Raises ProtectedError.

    • models.SET_NULL: sets the foreign key to NULL when the referenced object is deleted. Requires null=True on the ForeignKey field. (e.g., delete a User, their Posts' user field becomes NULL).

      We also have models.SET_DEFAULT, models.DO_NOTHING, and models.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 many Courses, and a Course can have many Students.

    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 a StudentCourse 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 return return 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 an HttpRequest 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.

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