Why Django in this project?


As I mentioned in the first post, the goal was to create a technical blog to reduce the friction between writing and reading. For that, organization, backup control, deployment, and custom features were a priority.

In the post LearningSea Architecture and Decisions (overview), I briefly mention the main reason for choosing Django.

Still, the framework was chosen mainly because it provides everything a content-oriented application needs without requiring these components to be built from scratch;

Using a framework meant I didn't need to reinvent the predictable parts of a web application. In practice, this meant I didn't have to make a series of basic decisions:

  • modeling and accessing data → the ORM already handles that
  • managing content → the admin already gives me a complete panel
  • rendering pages → the template system is already integrated
  • handling authentication → it comes ready out of the box

Django gave me the structure — but turning that into a functional blog still requires real decisions about how those pieces should behave — and that's where the real challenge lies.

Beyond that, what happens when someone makes a request? That's exactly the flow we'll walk through next.

Request flow


When someone makes a request to the blog, the flow follows, in a simplified form, this path:

Client sends HTTP Request → Web Server → Application Server → Django creates HttpRequest → Middleware stack (request phase) → URL resolver → View (orchestrates the logic) → ORM → SQL → Database → Model instance → Template rendering → HTML → Middleware stack (response phase) → Django returns HttpResponse → Application Server → Web Server → Client (browser renders HTML)

Let's go deeper into some of these parts for a better understanding of the pipeline above.

Middleware


Middlewares exist to resolve global system concerns in a single place, ensuring that every request already arrives at the application with a series of validations, more secure and with the necessary context.

We can think of middlewares as a chain of interceptors between the moment the request arrives and the moment the response leaves.

Each middleware can read, modify, or block a request. On the way back, they can also modify the response.

Without middleware, we would need to implement these validations in each view individually, which quickly becomes repetitive, hard to maintain, and error-prone.

In practice, this means that when the request reaches the view, it is more "clean" — it has already been filtered, validated, and enriched with important information, such as the authenticated user, for example.

Example:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware', # Hardens security at the HTTP level and adds security headers (HSTS, etc.)
    'django.contrib.auth.middleware.AuthenticationMiddleware', # Identifies the user, so we can access request.user
    'axes.middleware.AxesMiddleware',  # Monitors login attempts, blocks IP after repeated failures. Needs to be last
]

URL resolver


After the request passes through the middleware chain, Django needs to decide which part of the application should respond to that URL.

This is where the URL resolver comes in.

Simply put, urls.py works like a routing table: it receives the request path and tries to find a pattern that matches that URL. This check happens from top to bottom, and the first pattern that matches the URL is the one chosen.

In the context of the blog, this means mapping paths such as:

  • /about/ → static page
  • /post/<slug:slug>/ → a specific post page

An important detail here is that the order of routes matters. For example, a generic route like <slug:slug>/ can capture almost any URL, so if it comes before more specific routes like about/, it may intercept requests it shouldn't.

This shows that routing is not just a declarative configuration — it also involves decisions about how content should be accessed.

In practice, this translates to something like:

urlpatterns = [
    path('', views.PostListView.as_view(), name='post_list'),
    path('about/', views.AboutView.as_view(), name='about'),
    path('post/<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'),

Views


Once the URL is resolved, Django knows exactly which view should be called to respond to that request.

A view is responsible for transforming a request into a response.
If we think of an analogy, we could describe it this way: when a request arrives at a web application, it is like someone entering a triage center with a specific problem. The URL works as the description of that problem, and the routing system decides which "specialist" that case should be forwarded to. That specialist is the view: it receives the request, analyzes the available data, looks up information if necessary, and makes a decision. In the end, it returns a result — which can be a page, data in JSON format, or even a redirect. In essence, the view is the point where the application stops merely forwarding and starts actually thinking and acting on the request.

In the context of the blog, this means a series of decisions:

  • which language should be displayed
  • which posts the user can see
  • whether there is an active search
  • how that data should be organized

Since these decisions appear in practically all views — such as identifying the language, applying access rules, and organizing data — it makes sense to structure this in a reusable way.
For that, I used class-based views (CBVs) and mixins.

Class-based views

In Django, there are two main ways to write views: function-based views (FBV) and class-based views (CBV). While FBVs offer full control over the flow, CBVs encapsulate common behavioral patterns.

In my case, I used class-based views like ListView and DetailView, which already implement most of the logic needed to list objects and display details. This reduces the amount of repetitive code and allows focusing only on the parts that really need customization, such as the queryset definition.

In practice (simplified example):

class PostListView(ListView):
    model = Post  # defines which model will be used in the listing
    template_name = 'blog/post_list.html'  # template that will be rendered
    context_object_name = 'posts'  # variable name in the template

    def get_queryset(self):
        return Post.objects.order_by('-created_at')  # fetches and orders the posts

Mixin

Mixins are a way to add reusable behavior to a class through inheritance. Unlike a full class, a mixin doesn't exist on its own — it represents just a "piece" of logic that can be combined with other classes.

In the blog, I use two main mixins:

  • LanguageMixin: responsible for determining the language of the request based on the URL (/en/ or default in Portuguese)
  • StaffOrPublishedMixin: responsible for defining which data can be accessed, allowing regular users to see only published posts, while users with staff permission can view all of them

These behaviors appear in practically all views. By extracting them into mixins, I avoid code repetition and centralize important rules in a single place.

In practice (example):

class StaffOrPublishedMixin:
    def get_base_queryset(self):
        # if staff, returns all posts
        if self.request.user.is_staff:
            return Post.objects.all()

        # otherwise, returns only published posts
        return Post.objects.filter(status='published')

This second case introduces an important concept: the base queryset.

The base queryset represents the initial set of allowed data before any other filter is applied. In the case of the blog, it defines whether the query should return all posts or only the published ones, depending on the user. From that point on, each view can specialize the behavior — sorting, filtering, or searching — without needing to worry about access rules again.

QuerySet and search

From the base queryset, the views build more specific queries. This is done using Django's ORM, which allows writing queries in Python that will be translated to SQL.

An important detail is that the queryset does not execute immediately. It is built incrementally — with filters, orderings, and conditions — but is only evaluated when the data is actually needed, usually at the moment the template iterates over the results. This behavior is known as lazy evaluation.

This allows queries to be composed efficiently. For example, in the post listing, the flow goes roughly like this:

  • starts with the base queryset (access control)
  • applies ordering by date
  • checks whether a search term exists
  • if so, adds filters using Q objects, allowing multiple fields to be combined with OR conditions

This approach results in a single final query to the database, even though it was built in several steps.

The search itself is based on icontains, which allows finding partial text occurrences in a simple way. It is a sufficient solution for the blog's context, although it is not the most scalable for large volumes of data.

In practice (simplified example):

from django.db.models import Q

queryset = Post.objects.all()  # initial base query

# applies search filter across multiple fields
queryset = queryset.filter(
    Q(title__icontains=query) |      # searches in the title
    Q(excerpt__icontains=query) |    # searches in the excerpt
    Q(content__icontains=query)      # searches in the content
)

View organization

With these elements, the views follow a relatively consistent structure.

The PostListView is responsible for listing posts, applying ordering, search, and additional context for the template. It works like a pipeline: it receives the state of the request, transforms that into a database query, and returns the data ready for rendering.

The PostDetailView works with a single object, resolved from the URL slug. In addition, it decides which version of the content should be displayed based on the language, including a fallback to Portuguese when no translation is available.

The category and tag filter views reuse the same listing logic, changing only the filtering criterion. This shows how composition allows behavior to vary without duplicating structure.

Finally, the AboutView represents the simplest case: a static page rendered based on the language, with no need for database access.

These decisions show that the view doesn't just return data — it defines how the system behaves in response to each request.

Models


Up to this point, the application has already passed through URL routing, which directs the request to a view. The view builds a queryset and passes it to the template, which consumes that data and triggers query execution. It is at this point that the role of models becomes clearer.

Models in Django are Python classes that represent database tables. Each instance of a model corresponds to a row, and each attribute represents a column.

Models are one of Django's central abstractions, as they allow working with the database using only Python. This way, the concern becomes modeling the data, not writing the specific syntax of the DBMS, which also makes switching databases easier if necessary.

The blog's models are structured around three main entities: Post, Category, and Tag.

In the models, the slug is automatically generated from the name or title at the time of saving. This avoids inconsistencies and eliminates the need to define URLs manually.

In addition, the model supports content in two languages (Portuguese and English), with separate fields. This choice simplifies writing and keeps the fallback logic controlled in the views.

Category and Tag

The Category and Tag models are simple, composed basically of name and slug. The save() method is overridden only to automatically generate the slug from the name.

This relationship becomes clearer in the Post model.

Post

In the Post model, Category is used as a ForeignKey, which means each post has only one category.
Tag, on the other hand, is used as a ManyToMany, allowing a post to be associated with multiple tags. This type of relationship uses an intermediary table automatically generated by Django, responsible for connecting posts and tags.

In practice:

class Post(models.Model):
...
    category = models.ForeignKey(
        Category,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='posts'
    )
    tags = models.ManyToManyField(
        Tag,
        blank=True,
        related_name='posts'
    )
...

Editor type

The Post model also defines the type of editor used for the content:

class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = 'draft', 'Draft'
        PUBLISHED = 'published', 'Published'

    class EditorType(models.TextChoices):
        HTML = 'html', 'HTML (Summernote)'
        MARKDOWN = 'markdown', 'Markdown'
...

The reason for this choice was explained in previous posts:

Due to previous study projects in Django, I was familiar with the Summernote text editor.
For greater practicality and versatility, I also chose to include support for writing in Markdown. For developers, it is a faster and more precise way to structure content compared to a WYSIWYG editor like Summernote, in addition to allowing writing outside the application, in any text editor.

Overriding the save() method

Here comes one of the most important details of the Post model. This behavior is implemented directly in the save() method.
It is here that content processing happens before being persisted to the database. When the editor used is Markdown, the text is converted to HTML using the markdown library. This HTML then goes through a sanitization process.

This step is essential. It ensures that the stored content is safe, removing possible malicious elements. Since this validation happens at write time, the system can render the HTML directly in the template using |safe, without compromising the application's security.

In practice:

class Post(models.Model):
...
    if self.editor_type == self.EditorType.MARKDOWN and self.content_markdown:
        self.content = markdown.markdown(
            self.content_markdown,
            extensions=['fenced_code', 'tables', 'toc', 'nl2br']
        )
    self.content = sanitize_content(self.content)
...

In the end, models don't just define the structure of the data — they also control how that data enters and is validated in the system, ensuring consistency, security, and predictability.

Templates


Up to this point, the data has already been processed by the middlewares, routed by URL routing, processed by the view, and the queryset is ready, containing the data that will be consumed by the template. Now, the question is how that data will be presented to the client (browser).

Templates solve exactly this problem — we write HTML normally, with the difference that we can use Django's own template language to access variables and apply simple logic within the template. This is possible because Django processes the template before sending the HTML to the client.

Another extremely useful point is the possibility of template inheritance, as in the case of a base.html, which contains the common structure of the site. From it, other templates can override specific blocks using {% extends %}. It is also possible to reuse smaller parts with {% include %}, such as header.html and footer.html.

Example of what the template language looks like inside HTML:

{% block content %}
  {% for post in page_obj %}
  <article class="post-card">
    {% if site_setup.show_post_miniature_image %}
      {% if post.cover %}
        <div class="post-image">
      <a href="{{ post.get_absolute_url }}"><img src="{{ post.cover.url }}" alt="Image for the {{ post.title }}"></a>
        </div>
      {% endif %}
    {% endif %}

    <div class="post-content">
      <a href="{{ post.get_absolute_url }}"><h2 class="post-title">{{ post.title }}</h2></a>
      <p class="post-excerpt">
        {{ post.excerpt }}
      </p>
      <a class="post-link" href="{{ post.get_absolute_url }}">Read -></a>
    </div>
  </article>
  {% empty %}
    <h2>No posts yet.</h2>
  {% endfor %}
{% endblock %}

In this example, you can see how the template system allows rendering dynamic data directly in HTML.

All template processing happens on the server side — the HTML is only sent to the browser once it's fully ready. This is known as SSR (Server Side Rendering).

An alternative is CSR (Client Side Rendering), where the browser makes additional requests via JavaScript to fetch data, usually through an API. In this model, the backend returns only data (usually in JSON), and the frontend is responsible for assembling the interface.

Comparing the two: in SSR the server processes the data and assembles the HTML, while in CSR the server delivers only the data, and the HTML is built in the browser.

In the context of the blog, SSR fits naturally. The content is mostly static, does not require complex interactivity, and benefits from being delivered ready to the user.
This reduces the complexity of the application, eliminates the need for an extra frontend layer, and keeps the focus on what really matters: writing and serving content.

Admin Panel


The Admin Panel provides a ready-made interface for managing blog content — essentially a full CRUD for posts, categories, and tags, as well as user and permission management.

The main advantage is that all of this comes integrated with the models, with no need for manual implementation.

Without using the Admin Panel, it would be necessary to build this layer from scratch: templates for forms, logic for creating, updating, and removing data, field validation, authentication control and CSRF protection, as well as the interface itself.

In practice, this would mean developing a small CMS (Content Management System) just to manage the application's content.

Example of the admin.py configuration:

# Registers the Post model in Django Admin
@admin.register(Post)
class PostAdmin(SummernoteModelAdmin):

    # Defines which fields use the rich text editor (Summernote)
    summernote_fields = ['content', 'content_en']
    # when the editor type is HTML, these fields are used

    # Fields displayed in the post listing
    list_display = ['title', 'editor_type', 'status', 'created_at']
    # includes the editor type to quickly identify how the content was written

    # Automatically generates the slug from the title
    prepopulated_fields = {'slug': ('title',)}

    # Allows searching posts by title
    search_fields = ['title']

    # Side filters in the admin
    list_filter = ['status', 'editor_type', 'created_at']
    # makes it easy to filter by posts in Markdown or HTML

    # Field organization in the edit form
    fieldsets = [
        ('Main content', {
            'fields': [
                'title',
                'slug',
                'editor_type',       # defines whether the content will be HTML or Markdown
                'content_markdown',  # used when editor_type = Markdown
                'content',           # final HTML (rendered or written directly)
                'status',
            ]
        }),
        ('Translation (English)', {
            'fields': [
                'content_markdown_en',
                'content_en',
            ],
            'classes': ['collapse'],  # starts hidden, expands on click
        }),
    ]

This example shows how Django Admin can be adapted to the project's actual writing flow. The editor_type field allows switching between HTML and Markdown, while Summernote facilitates editing when content is written directly in HTML. In the case of Markdown, the text is stored separately and converted before being displayed. In addition, the fieldsets organize the fields and keep the English version collapsed by default, reducing the visual complexity of the form.

Conclusion


Django uses the MVT (Model-View-Template) pattern, which is conceptually very close to MVC (Model-View-Controller). The main difference lies in the naming: in Django, the "view" takes on the role of the controller, while the template is responsible for the presentation layer.

This architecture defines a specific path to follow when building the application. This makes development faster, more predictable, and easier to maintain.

After understanding each of these parts, the application flow can be summarized simply:

Request → Middleware → URL → View → DB → Template → Response

The request arrives at the server, passes through the processing steps, and returns as a response ready for the browser.