Microservices with Django: Patterns and Anti-patterns
“Just use microservices” is terrible advice. Breaking a monolith is hard, and doing it wrong creates a distributed monolith—all the complexity, none of the benefits.
Here’s what actually works with Django.
When to Consider Microservices
Good reasons:
- Different parts need different scaling
- Teams need independent deployment
- Different technology requirements per service
- Organizational boundaries exist
Bad reasons:
- “It’s what Netflix does”
- Resume-driven development
- Avoiding fixing your monolith’s problems
- Vague performance concerns
Most applications should stay monolithic. Django monoliths can handle enormous scale.
The Monolith First Approach
Start with a modular monolith:
myproject/
├── users/ # Could become service
├── orders/ # Could become service
├── payments/ # Could become service
├── shipping/ # Could become service
└── common/ # Shared utilities
Clear app boundaries. Minimal cross-app imports. This is your extraction roadmap.
Communication Patterns
Synchronous (REST/gRPC)
# Service A calling Service B
import requests
def get_user_orders(user_id):
response = requests.get(
f'http://orders-service/api/users/{user_id}/orders/',
timeout=5
)
response.raise_for_status()
return response.json()
Pros: Simple, familiar, request-response Cons: Tight coupling, cascading failures, latency
Asynchronous (Message Queues)
# Publishing an event
from celery import Celery
app = Celery('orders')
@app.task
def order_created(order_id):
# Publish event
publish_event('order.created', {'order_id': order_id})
# Consuming events
@app.task
def handle_order_created(event):
order_id = event['order_id']
# Process shipping, notifications, etc.
Pros: Loose coupling, resilience, scalability Cons: Complexity, eventual consistency, debugging difficulty
Data Management
The biggest challenge: shared database or separate?
Shared Database (Anti-pattern… usually)
# Orders service
from users.models import User # Don't do this!
class Order(models.Model):
user = models.ForeignKey(User, ...) # Cross-service reference
This creates hidden coupling. You can’t deploy services independently.
Database per Service
Each service owns its data:
# Orders service
class Order(models.Model):
user_id = models.IntegerField() # Just the ID, not a FK
# Fetch user details via API when needed
You’ll need to handle:
- Data duplication: Cache copies of needed data
- Eventual consistency: Accept that data syncs with delay
- Distributed transactions: Often avoided; use saga pattern
API Gateway Pattern
┌─────────────┐
Browser ───────────▶│ API Gateway │
└──────┬──────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Users │ │ Orders │ │ Payments │
└───────────┘ └───────────┘ └───────────┘
The gateway handles:
- Routing
- Authentication
- Rate limiting
- Request aggregation
Use Kong, AWS API Gateway, or build with Django.
Service Discovery
Services need to find each other:
# Hard-coded (bad for production)
ORDERS_SERVICE_URL = 'http://orders:8000'
# Environment-based (better)
ORDERS_SERVICE_URL = os.environ.get('ORDERS_SERVICE_URL')
# Service discovery (best for dynamic environments)
# Use Consul, Kubernetes DNS, or similar
Circuit Breaker Pattern
Prevent cascading failures:
import pybreaker
orders_breaker = pybreaker.CircuitBreaker(
fail_max=5,
reset_timeout=60
)
@orders_breaker
def get_orders(user_id):
response = requests.get(f'{ORDERS_URL}/users/{user_id}/orders/')
response.raise_for_status()
return response.json()
# Handle open circuit
try:
orders = get_orders(user_id)
except pybreaker.CircuitBreakerError:
orders = [] # Graceful degradation
Django-Specific Patterns
Shared Auth with JWT
# Auth service issues JWT
# Other services verify JWT
import jwt
def get_user_from_token(token):
payload = jwt.decode(
token,
settings.JWT_PUBLIC_KEY,
algorithms=['RS256']
)
return payload['user_id']
class JWTAuthenticationMiddleware:
def __call__(self, request):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if token:
request.user_id = get_user_from_token(token)
return self.get_response(request)
Shared Libraries
Extract common code into packages:
shared-django-lib/
├── auth/
├── logging/
├── metrics/
└── utils/
Version carefully. Don’t couple services through shared libraries.
Anti-Patterns to Avoid
Distributed Monolith
Services that must deploy together aren’t microservices.
Chatty Services
Too many inter-service calls indicate wrong boundaries.
Shared Database
Defeats the purpose of independent services.
No Observability
You need tracing, centralized logging, and metrics. More services means more failure modes.
Over-Extraction
Don’t start with 20 services. Extract when pain justifies it.
Observability Stack
Essential for microservices:
# Distributed tracing with Jaeger
from opentelemetry import trace
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("get_order"):
order = orders_service.get(order_id)
- Tracing: Jaeger, Zipkin
- Logging: ELK, Loki
- Metrics: Prometheus, Grafana
Final Thoughts
Microservices solve organizational problems, not technical ones. If you have a small team, a monolith is simpler and faster to develop.
When you do extract services:
- Start with clear boundaries in your monolith
- Extract one service at a time
- Invest heavily in observability
- Prefer async communication where possible
The goal is independent deployment and scaling. If you’re not achieving that, reconsider.
Monoliths aren’t the enemy. Complexity is.