The Four Doormen of Django: Middleware, Decorators, Views, and Signals
Every request that hits your app passes through invisible hands. Here’s who they are.
The Four Doormen of Django: Middleware, Decorators, Views, and Signals
Every request that hits your app passes through invisible hands. Here’s who they are.

I remember the first time I built a Django app that actually had users. Everything worked fine — until it didn’t. Someone was hammering the login endpoint. Another user’s account creation wasn’t sending a welcome email. A third endpoint was leaking data it shouldn’t have.
All three problems had the same root cause: I was stuffing everything into views and had no idea there were better, cleaner places for each of these concerns.
Django has four distinct layers where you can intercept and handle things. Each one has a different job, a different personality, and a different time when it shows up to work.
Let me walk you through all four using plain language and simple examples.
First, a Quick Mental Model
Think of a Django application like a restaurant.
A customer walks in (HTTP request), gets seated, orders food, the kitchen makes it, and the plate comes back out (HTTP response).
Now imagine there are four different roles in this restaurant:
- The Bouncer at the door — checks everyone before they even get to their table
- The Waiter assigned to your table — gives you specific treatment based on who you are
- The Chef — actually makes the food
- The Kitchen Bell — rings when something happens, triggering other actions behind the scenes
These are Middleware, Decorators, Views, and Signals. Let’s meet them one by one.
1. Middleware — The Bouncer
Middleware runs on every single request, before it reaches any view. It also runs on every response going back out.
It doesn’t know or care what endpoint you’re hitting. It just stands at the door and processes everything that comes through.
class LoggingMiddleware:
def __init__(self, get_response):
self.get_response = get_response # the rest of the chain
def __call__(self, request):
# This runs BEFORE the view
print(f"Incoming request: {request.method} {request.path}")
response = self.get_response(request) # pass to the view
# This runs AFTER the view (on the way back out)
print(f"Response status: {response.status_code}")
return response
See that structure? Code before get_response runs on the way in. Code after runs on the way out. The middleware wraps the entire request like a sandwich.
Here’s a more practical example — a simple rate limiter that blocks users who send too many requests:
from django.core.cache import cache
from django.http import JsonResponse
class RateLimitMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
ip = request.META.get('REMOTE_ADDR')
key = f'hits:{ip}'
hits = cache.get(key, 0)
if hits >= 100:
return JsonResponse({'error': 'Too many requests'}, status=429)
cache.set(key, hits + 1, timeout=60)
return self.get_response(request)
Every request from every user passes through this before touching any view. No view needs to know rate limiting exists.
Django applies middleware in order, like layers of an onion. Each layer wraps the next:
Request comes in
→ CorsMiddleware
→ SecurityMiddleware
→ RateLimitMiddleware
→ SessionMiddleware
→ AuthMiddleware
→ Your View
← AuthMiddleware
← SessionMiddleware
← RateLimitMiddleware
← SecurityMiddleware
← CorsMiddleware
Response goes out
This is why order matters. If your rate limiter runs before the auth middleware, request.user isn't set yet — the user hasn't been identified. Run it after auth, and you can limit by user ID instead of just IP address.
Register middleware in settings.py:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'myapp.middleware.RateLimitMiddleware', # ← add yours here
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
]
When to use middleware:
- Rate limiting
- Logging every request
- Adding security headers to all responses
- Handling CORS
- Blocking specific IPs
- Anything that should apply to most or all endpoints
When NOT to use middleware:
- Logic specific to one endpoint
- Business logic
- Anything that needs to know about specific URL parameters
2. Decorators — The Waiter
Decorators are specific to individual views. Instead of touching every request, they wrap a single function and add behavior to it.
You’ve definitely seen them before:
from django.contrib.auth.decorators import login_required
from django.views.decorators.cache import cache_page
@login_required
@cache_page(60 * 15) # cache for 15 minutes
def dashboard(request):
stats = get_user_stats(request.user)
return render(request, 'dashboard.html', {'stats': stats})
Those @ symbols are decorators. Each one adds a layer of behavior around your view specifically.
You can write your own too. Say you want to restrict a view to users on a paid plan:
from functools import wraps
from django.http import JsonResponse
def paid_plan_required(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if not request.user.profile.is_paid:
return JsonResponse(
{'error': 'This feature requires a paid plan'},
status=403
)
return view_func(request, *args, **kwargs)
return wrapper
@login_required
@paid_plan_required
def export_data(request):
data = generate_export(request.user)
return FileResponse(data, filename='export.csv')
The decorator intercepts the call, checks the plan, and either blocks it or passes it through. The view itself knows nothing about this check.
Multiple decorators stack from bottom to top — the one closest to the function runs first:
@decorator_a # runs second
@decorator_b # runs first (closest to the function)
def my_view(request):
...
With Django REST Framework the pattern is the same, just different decorators:
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
@api_view(['GET', 'POST'])
@permission_classes([IsAuthenticated])
def user_profile(request):
if request.method == 'GET':
return Response({'username': request.user.username})
# handle POST...
When to use decorators:
- Permission checks for specific views
- Caching a specific endpoint
- Logging for one particular view
- Throttling one specific endpoint differently from others
- Input validation specific to one endpoint
When NOT to use decorators:
- Logic that needs to apply everywhere — use middleware instead
- Complex business logic — keep that in services or models
3. Views — The Chef
The view is where the actual work happens. This is the kitchen.
It receives the request, does whatever needs to be done — queries the database, calls an external API, processes a file — and returns a response.
from django.http import JsonResponse
from .models import Article
def article_list(request):
articles = Article.objects.filter(published=True).values(
'id', 'title', 'author', 'published_at'
)
return JsonResponse({'articles': list(articles)})
That’s the simplest form. For more complex cases, Django REST Framework gives you class-based views:
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
class ArticleView(APIView):
def get(self, request, article_id):
try:
article = Article.objects.get(id=article_id, published=True)
except Article.DoesNotExist:
return Response({'error': 'Not found'}, status=status.HTTP_404_NOT_FOUND)
serializer = ArticleSerializer(article)
return Response(serializer.data)
def put(self, request, article_id):
article = Article.objects.get(id=article_id, author=request.user)
serializer = ArticleSerializer(article, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
Class-based views let you organize GET, POST, PUT, DELETE handlers cleanly in one place.
The view is the destination. Everything else — middleware, decorators — exists to either prepare the request before it gets here, or protect the view from things it shouldn’t have to deal with.
When to use views:
- Always. Every endpoint needs a view.
- Keep them thin — let models and services do the heavy lifting
- The view’s job is: receive input → call the right function → return output
Common mistake: Putting too much logic directly in the view. A view that’s 200 lines long is doing too much. Pull the business logic into a separate function or service class, and let the view just coordinate.
# ❌ Fat view — doing too much
def place_order(request):
# 50 lines of inventory checking
# 30 lines of payment processing
# 20 lines of email formatting and sending
# 10 lines of analytics tracking
...
# ✅ Thin view — delegating properly
def place_order(request):
order = OrderService.create(request.user, request.data)
return Response({'order_id': order.id}, status=201)
4. Signals — The Kitchen Bell
Signals are Django’s way of saying: “something just happened — does anyone care?”
When a model gets saved, Django can ring a bell. Any part of your app that registered interest in that bell gets notified and can react.
The most common example: automatically create a user profile when a new user registers.
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import UserProfile
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
Whenever a new user is saved to the database, Django fires the post_save signal. Our receiver catches it and automatically creates a profile. The user creation code knows nothing about profiles — the profile creation knows nothing about how users are created. They're completely decoupled.
Django has built-in signals for common events:
pre_save # fires before a model is saved
post_save # fires after a model is saved
pre_delete # fires before a model is deleted
post_delete # fires after a model is deleted
Another practical example — send a welcome email after registration:
from django.core.mail import send_mail
@receiver(post_save, sender=User)
def send_welcome_email(sender, instance, created, **kwargs):
if created:
send_mail(
subject='Welcome!',
message=f'Hi {instance.username}, thanks for signing up.',
from_email='hello@yourapp.com',
recipient_list=[instance.email],
)
You can also create your own custom signals for things Django doesn’t have built-in events for:
from django.dispatch import Signal
# Define it somewhere central
payment_received = Signal()
# Fire it from your payment processing code
payment_received.send(sender=Payment, user=user, amount=amount)
# Catch it from your notifications module
@receiver(payment_received)
def notify_user_after_payment(sender, user, amount, **kwargs):
Notification.objects.create(
user=user,
message=f'Payment of ${amount} received. Thank you!'
)
The payment code doesn’t need to know about notifications. The notification code doesn’t need to know about payments. The signal connects them without either knowing about the other.
When to use signals:
- You need to react to model save/delete events
- Decoupling two parts of your app that shouldn’t directly import each other
- Side effects that shouldn’t clutter the main flow (emails, analytics, audit logs)
- Reacting to third-party app events you can’t modify directly
When NOT to use signals:
- When a direct function call is simpler — signals add indirection that makes code harder to trace
- For critical business logic that must always run — signals can fail silently
- When you need a return value from the handler — signals don’t support that cleanly
Side by Side: Which One When?
Situation Use Rate limit every API call Middleware Add security headers to all responses Middleware Block unauthenticated users from one view Decorator Cache one specific endpoint Decorator Fetch articles and return JSON View Process a file upload View Create a profile when user signs up Signal Send email after order is placed Signal Log every incoming request Middleware Log when a specific model changes Signal Restrict a view to admin users only Decorator Reject oversized request bodies Middleware
How They All Work Together
Here’s the full journey of a single request to a POST /api/orders/ endpoint:
1. Request arrives at the server
2. MIDDLEWARE runs (in order, outermost first):
├── CorsMiddleware → adds CORS headers
├── SecurityMiddleware → adds security headers
├── RateLimitMiddleware → checks rate limit, returns 429 if exceeded
├── SessionMiddleware → loads session data
└── AuthMiddleware → identifies the user from token
3. URL router matches /api/orders/ → PlaceOrderView
4. DECORATORS run (bottom to top):
├── @permission_classes([IsAuthenticated]) → is user logged in?
└── @api_view(['POST']) → is it a POST request?
5. VIEW executes:
├── Validates input data
├── Checks inventory
├── Creates the Order in the database
└── Returns 201 Created with order details
6. ORDER SAVE triggers SIGNALS:
├── post_save → send_confirmation_email()
├── post_save → update_inventory_count()
└── post_save → track_analytics_event()
7. MIDDLEWARE runs again (in reverse, on the way out):
└── Each middleware can modify the response before it leaves
8. Response reaches the client
Each layer handles exactly one concern. The middleware doesn’t know about orders. The view doesn’t know about emails. The signal doesn’t know about HTTP. They each do their job and stay out of everyone else’s way.
The Mistake Everyone Makes
When developers are new to Django, they stuff everything into views.
Rate limiting logic in the view. Permission checks in the view. Welcome email sending in the view. Analytics tracking in the view.
Views become 300-line monsters doing twelve different things. When something breaks you have no idea which of the twelve things caused it.
The four layers exist specifically to prevent this. Each concern has a home:
- Cross-cutting concerns (applies to everything) → Middleware
- Per-endpoint concerns (applies to specific views) → Decorators
- The actual work → Views
- Reactions to events → Signals
When you put things in the right place, each piece of code becomes small, focused, and easy to change without breaking something else.
That’s the real payoff of understanding these four layers — not just knowing what they are, but knowing which one to reach for the moment a new requirement lands on your desk.
Have questions or want to go deeper on any of these? Drop a comment below. If this helped you, consider sharing it with someone who’s just getting started with Django.
메타데이터
- post_id
- 4e3b50a1326e
- slug
- the-four-doormen-of-django-middleware-decorators-views-and-signals-4e3b50a1326e
- url
- https://medium.com/@arsalkhan963/the-four-doormen-of-django-middleware-decorators-views-and-signals-4e3b50a1326e
- canonical_url
- https://medium.com/@arsalkhan963/the-four-doormen-of-django-middleware-decorators-views-and-signals-4e3b50a1326e
- author_url
- https://medium.com/@arsalkhan963
- status
- ok
- fetched_at
- 2026-06-09 15:37:30