After 6+ years working on a large-scale SaaS application, I’ve learned what works, what doesn’t, and what patterns truly stand the test of time in Django development. This isn’t theoretical advice—these are battle-tested practices from building real applications that serve real users.
The Service Layer vs Repository Debate
Service Layer: A Clear Winner
The service layer pattern makes complete sense for Django applications. Here’s why:
Centralized Business Logic: When creating an organization, you need to add an owner, send a welcome email, and handle permissions. This logic gets reused across your API, Django admin, class-based views, and Celery tasks. The service layer provides a single source of truth.
Locality of Behavior: Keep business logic, permission checks, and side effects in the same place. This makes your code more predictable and easier to debug.
Clean Architecture Flow:
Request → View/Controller → Service → Manager/QuerySet → Database
Here’s a practical example:
# services/organization.py
from django.db import transaction
from django.core.exceptions import PermissionDenied
class OrganizationService:
@transaction.atomic
def create_organization(self, user, name, **kwargs):
if not user.can_create_organization():
raise PermissionDenied("User cannot create organizations")
org = Organization.objects.create(name=name, **kwargs)
org.add_owner(user)
self.send_welcome_email(user, org)
return org
Repository Pattern: Skip It
The repository pattern adds unnecessary abstraction in Django:
- Django’s Active Record pattern + custom managers already handle data access elegantly
- 99% of Django projects don’t need this level of decoupling
- Only consider it if you’re planning to migrate away from Django’s ORM entirely
Database Access: Managers Are Your Friends
Custom managers are the Django way to handle data access logic:
class OrganizationManager(models.Manager):
def for_user(self, user):
return self.filter(members=user)
def is_active(self):
return self.filter(active=True)
def with_member_count(self):
return self.annotate(member_count=Count('members'))
Naming Conventions for Manager Methods:
for_user(),for_company()- filtering by contextis_active(),has_email()- boolean conditionsexclude_deleted(),not_expired()- exclusionswith_total_orders()- annotationscreate_for_user()- creation methods
Keep managers focused on data access—no side effects, no email sending, no business logic.
Performance: Database First
Before implementing Redis caching, optimize your database access:
N+1 Queries: The most common Django performance killer. Use prefetch_related() and select_related() religiously.
Essential Optimizations:
- Pagination for large datasets
- Database-level counting instead of Python iteration
@cached_propertyfor expensive computations- GZip middleware for response compression
# Bad
users = User.objects.all()
for user in users:
print(user.organization.name) # N+1 query
# Good
users = User.objects.select_related('organization')
for user in users:
print(user.organization.name) # Single query
Django Signals: Just Don’t
Django’s documentation warns against signals multiple times for good reason:
- Hidden side effects make debugging nightmarish
- Difficult to trace why something isn’t working
- Race conditions and ordering issues
Better alternatives:
- Override
save()for model-specific logic - Use service layers for business logic
- Keep side effects explicit and traceable
Views: Keep Them Thin
Views should be lightweight controllers:
class OrganizationCreateView(CreateView):
def form_valid(self, form):
try:
org = OrganizationService().create_organization(
user=self.request.user,
**form.cleaned_data
)
messages.success(self.request, "Organization created successfully")
return redirect('org:detail', org.pk)
except PermissionDenied as e:
messages.error(self.request, str(e))
return self.form_invalid(form)
Class-Based Views: They shine when you have repeated patterns (CRUD + filtering + HTMX partials), but avoid mixin hell. The Django team introduced a security vulnerability in 2016 due to CBV complexity—keep it simple.
Project Structure That Scales
Core App Philosophy
Create a core app as your single source of truth:
# core/models.py
class BaseModel(models.Model):
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
# core/exceptions.py
class BusinessLogicError(Exception):
pass
# core/messages.py
class Messages(StrEnum):
PERMISSION_DENIED = "You don't have permission for this action"
ORGANIZATION_CREATED = "Organization created successfully"
Utils vs Helpers
utils.py: Generic functions, framework-agnostic
- Text formatting, slugification, date conversion
helpers.py: Django-specific shortcuts
- Form error handling, template utilities
Modular Apps
One app = one business domain. Avoid catch-all apps like common or utils.
Data Transfer: DTOs Over Models
Don’t always return Django models from services—they carry heavy baggage:
- All database fields
- Attached managers
- Django metadata
- Lazy-loaded relations
- State tracking for dirty checking
Consider lightweight DTOs or serialized data for better performance and explicit contracts.
Modern Django Development
Docker: Non-Negotiable
# Your docker-compose up should start everything
# Include .env.example and clear README
# Zero friction for new developers
API Versioning From Day One
# urls.py
urlpatterns = [
path('api/v1/', include('api.v1.urls')),
]
HTMX: The Pragmatic Choice
HTMX covers 90% of interactive web app needs:
- 14KB footprint
- Leverages Django’s strengths (templates, routing, permissions)
- Forces you to think in reusable partials
- No need for separate API + SPA unless you have a dedicated frontend team
Transactions and Atomicity
Use @transaction.atomic granularly, not globally:
@transaction.atomic
def create_user_with_profile(self, email, **profile_data):
user = User.objects.create_user(email=email)
Profile.objects.create(user=user, **profile_data)
return user
Development Workflow Improvements
Management Commands Over Endpoints
Automate with Django commands rather than exposing endpoints:
# management/commands/send_reports.py
class Command(BaseCommand):
def handle(self, *args, **options):
ReportService().send_weekly_reports()
Perfect for cron jobs, backend tasks, and Celery integration.
Internationalization from Day One
Enable i18n immediately—refactoring later is painful:
# settings.py
USE_I18N = True
LANGUAGES = [('en', 'English'), ('fr', 'French')]
Celery Over Django Async
For background tasks, Celery provides better decoupling and reliability than Django’s async features:
@shared_task
def send_welcome_email(user_id):
user = User.objects.get(id=user_id)
# Send email logic
Architecture in Layers
┌─────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ CBV │ DRF ViewSets │ HTMX Views │
└─────────────────┬───────────────────────────┘
│
┌─────────────────▼───────────────────────────┐
│ SERVICE LAYER │
│ Centralized Business Logic │
└─────────────────┬───────────────────────────┘
│
┌─────────────────▼───────────────────────────┐
│ DATA LAYER │
│ Models │ Managers │ QuerySets │
└─────────────────────────────────────────────┘
Anti-Patterns to Avoid
- Static method abuse: If you’re writing lots of
@staticmethod, you’re probably writing Java in Python - Mixin hell: Prefer clear inheritance over multiple cryptic mixins
- Fat models: Some logic belongs in models, but prefer services for complex business rules
- Global transactions: Use
@transaction.atomicprecisely where needed
Final Thoughts
These practices come from real experience scaling Django applications. Not every pattern fits every project, but these guidelines have consistently delivered maintainable, performant applications.
The key is finding the right balance between Django’s “batteries included” philosophy and clean architecture principles. When in doubt, choose explicitness over magic, and always prioritize maintainability over clever abstractions.
Remember: good architecture isn’t about following every pattern perfectly—it’s about making code that future you (and your teammates) can understand and modify with confidence.