This repository contains the exploration of a POC that was conceived in an attempt to decouple the template rendering part of a Django application from the primary application.
Let's say you have a Django app and you want to detach the templates from it and render them in a separate app. However, you'd still want to have access to all of the models and objects of the primary app.
This is not a pip installable library, merely a janky demonstration of the core idea.
At my workplace, we were thinking about detaching the template rendering portion of our primary Django app and delegate that to a separate app. This will enable us to develop and deploy the templates at a cadence that is different from the comparatively slower development pace of the main app. Also, the loose coupling implies that the development of these two entities can go on in their separate ways.
Assume that your primary Django app is called source and you want to decouple and develop the templates in another Django app named target. The goal is to establish a seamless communication channel between the two entities so that the target can house the templates and render them using the context objects sent from the source.
-
Both
sourceandtargetwill point to the same Postgres database. -
Both the entities will also use the same Redis cache backend.
-
The
sourceapp will use django-rest-framework to expose a GET API. Theviewclass of this API will build thecontextobject required to render a particular template in thetargetapp. -
When this API is called from the
targetapp, the correspondingviewclass will push thecontextto the cache. Then the API should return a key to retrieve thecontextfrom the cache. -
The
targetapp will then use the key returned by the API to fetch thecontextobject from the same cache backend. -
The
targetapp will use the retrievedcontextto render the templates. -
The serialization and the deserialization of the
contextobjects are taken care of by Django's built-in cache framework.
The repository contains the code for two Django applications, the source and the target app.
The source app looks like any other Django application. In this demonstration, most of the modules in the source app are empty. It uses the Postgres database as its primary data container and Redis for caching purposes. You can find the details in source/source/settings.py file.
It contains a single sub app named app. In the app, there are two models—Musician and Album. An Album has a foreign key relationship with a Musician.
# source/app/models.py
from django.db import models
class Musician(models.Model):
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
instrument = models.CharField(max_length=100)
class Album(models.Model):
artist = models.ForeignKey(
Musician,
on_delete=models.SET_NULL,
null=True,
related_name="albums",
)
name = models.CharField(max_length=100)
release_date = models.DateField()
num_stars = models.IntegerField()Now, if you look into the source/app/apis.py file, you'll see that's where the magic happens.
from uuid import uuid4
from django.core.cache import cache
from rest_framework import serializers, views
from rest_framework.response import Response
from .models import Album, Musician
class MusicContextSerializer(serializers.Serializer):
"""Your data serializer, define your fields here."""
key = serializers.CharField()
class MusicContextAPIView(views.APIView):
"""Returns the cache record key that contains the music context object."""
def get(self, request):
# Getting the object querysets.
musicians = Musician.objects.all()
albums = Album.objects.all()
# Generating key to store the context against.
music_context_key = str(uuid4())
# Building the context required to render the html.
music_context_val = {
"musicians": musicians,
"albums": albums,
}
# Storing the context in the shared cache.
cache.set(music_context_key, music_context_val)
# Returning the key to get the context from the other app.
data = {"key": music_context_key}
results = MusicContextSerializer(data).data
return Response(results)Here, we're exposing a GET API that is accessible from http://localhost:4000/api/v1/music_context. Notice how the get method first queries the database to build the musicians and albums queryset. Then it constructs the context and sends it to the cache with a random UUID key. The API then returns the key and it will later be used by the target app to retrieve the context object and render the template.
The directory structure of the target app mimics that of the source app. Here, too, the sub app is called app. Notice that the app folder contains a templates directory. The target app uses the context sent by the source and the templates/index.html template retrieves the data from the Postgres database using the querysets from the context.
In the target app, interesting things only happen in the target/app/views.py module and the target/templates/index.html file.
from __future__ import annotations
import typing
from dataclasses import dataclass
from http import HTTPStatus as http_status
import httpx
from django.core.cache import cache
from django.shortcuts import render
from django.views import View
if typing.TYPE_CHECKING:
from django.db.models import QuerySet
from target.app import models as target_models
@dataclass
class MusicContextShape:
"""This is going to be the shape of the retrieved context."""
musicians: QuerySet[target_models.Musician]
albums: QuerySet[target_models.Album]
class MusicView(View):
def get(self, request):
# Making an http GET request to get the 'key' associated with the context.
with httpx.Client(http2=True) as session:
res = session.get("http://source:4000/api/v1/music_context")
if res.status_code == http_status.OK:
key = res.json()["key"]
else:
raise httpx.ConnectError("cannot connect to server")
# Using the 'key' to retrieve the context object from the cache.
context = cache.get(key)
print(context["albums"][0].artist)
# Verifying if the context has the expected shape.
if context.keys() == MusicContextShape.__dataclass_fields__.keys():
# Injecting the context into the template.
return render(request, "index.html", context)
else:
raise ValueError("unexpected context shape")Here, the dataclass MusicContextShape is used to validate the expected context shape from the cache. Notice that inside the get method of the MusicView class, httpx library was used to make a get API call to the API exposed by the source app.
The API returns the cache key where the context lives inside the Redis database. The retrieved context is then injected into the template. If you take a look at the template, you'll see how it uses the queryset objects inside the context to display data. Here's the core content of the template:
...
<div class="container">
<div align="center">
<h2>Discography</h2>
</div>
<table class="table">
<thead class="thead-dark">
<tr>
<th>Artist Name</th>
<th>Preferred Instrument</th>
<th>Album Name</th>
<th>Album Released</th>
<th>Album Rating</th>
</tr>
</thead>
<tbody>
{% for album in albums %}
<tr>
<td>{{album.artist.first_name}} {{album.artist.last_name}}</td>
<td>{{album.artist.instrument}}</td>
<td>{{album.name}}</td>
<td>{{album.release_date}}</td>
<td>{{album.num_stars}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
...This demonstration uses Docker and Docker Compose to orchestrate the different entities required for it to work. The 4 primary building blocks of the POC are:
-
A
sourceapp instance which can be regarded as the primary application. -
A
targetapp instance which renders the template using the data sent by thesourceapp. -
Postgres database as the primary data container. Both the
sourceand thetargetapp points to this. However, thetargetapp never migrates or mutates the database. It has a read-only relationship with the main DB. -
Redis database as the shared cache channel between
sourceandtarget.
The simplified topology diagram looks roughly like this:
The docker-compose.yml file orchestrates the services in a stateless fashion. That means data is created and destroyed every time you spin up and put down the containers.
Migration and mutation of the primary database only happens in the source app. The target app isn't supposed to migrate or change the DB.
-
Make sure you've got Git, Docker, and Docker Compose installed on your machine.
-
Clone the repository and head over to the root directory.
-
In the root directory,
make run_serverson your terminal to spin up the orchestra. This will:- Start two instances of the
sourceand thetargetDjango apps. - Start a Postgres container that will be shared by the
sourceandtargetapps. - Start a Redis instance that will act as the shared cache between the two apps.
- Runs database migration from the
sourceapp. - Runs a script to fill in the Postgres database with some dummy data to render.
- Start two instances of the
-
The
sourceuses port4000, and thetargetapp uses port5000. -
On your browser, go to
http://localhost:4000/api/v1/music_context/. Thesourceapp is serving this API endpoint. Hitting the URL will create thecontext, send it to the cache for thetargetapp to pick it up. Also, you should be able to see the following page where the endpoint returns the cache key to fetch thecontextfrom the other side:
- On another tab, go to
http://localhost:5000/musics/. This should give you the following result:
Here, the context was passed into the cache by the source app. The target app then picks it up, injects it into the template, and renders the table.
-
Once you're done fooling around with it you can run the following command to shut down and clean up everything.
make stop_servers
-
Both
sourceandtargetwill need to have access to the same models. That means you'll have to copy over the models fromsourcetotarget. -
Both
sourceandtargetneed to point to the same Postgres database. The only benefit the pattern gives you is—you can create and send the complexcontextobjects with arbitrary queryset values from thesourceapp and use those in the templates that live in thetargetapplication without any further modification.



