Django gets you to a working product astonishingly fast, then quietly punishes you if you never impose structure. The ORM makes N+1 queries invisible, business logic sprawls across views and signals, and the 'one big app' becomes unmaintainable. Here is how I keep a Django + DRF backend healthy as it grows.
Draw real app boundaries
A Django 'app' should be a bounded domain, not a dumping ground. Split by business capability — bookings, billing, accounts — each owning its models, serializers, services and urls. Cross-app references go through well-defined interfaces, not deep imports into another app's internals.
apps/
bookings/
models.py
serializers.py
services.py # business logic lives here, not in views
views.py
urls.py
billing/
...
accounts/
...A service layer, not logic in views or signals
The most important discipline: business logic belongs in a service layer, not in views (which should be thin HTTP adapters) and definitely not in signals (which make control flow invisible and ordering fragile). A view validates, calls a service, returns a response.
# services.py — the business operation, framework-agnostic and testable
from django.db import transaction
def create_booking(*, guest, room, check_in, check_out) -> Booking:
if Booking.objects.overlapping(room, check_in, check_out).exists():
raise RoomUnavailable(room.id)
with transaction.atomic():
booking = Booking.objects.create(
guest=guest, room=room, check_in=check_in, check_out=check_out,
)
Invoice.objects.create_for(booking)
return booking# views.py — thin: validate, delegate, respond
class BookingCreateView(APIView):
def post(self, request):
s = BookingInSerializer(data=request.data)
s.is_valid(raise_exception=True)
booking = create_booking(guest=request.user, **s.validated_data)
return Response(BookingOutSerializer(booking).data, status=201)Resist the urge to put creation side effects in a post_save signal. Signals are implicit, fire on every save (including bulk ops and tests), and make the order of operations impossible to read. Call the side effect explicitly from the service instead.
Kill N+1 queries — the Django performance killer
The ORM's convenience hides its cost. Accessing a related object in a loop fires one query per row. Use select_related (SQL JOIN, for ForeignKey/OneToOne) and prefetch_related (separate query + join in Python, for reverse/ManyToMany).
# ❌ N+1: one query for bookings, then one per booking for guest + room
for b in Booking.objects.all():
print(b.guest.name, b.room.number)
# ✅ two queries total, regardless of row count
qs = Booking.objects.select_related("guest", "room").prefetch_related("addons")
for b in qs:
print(b.guest.name, b.room.number, [a.name for a in b.addons.all()])Install django-debug-toolbar in development and watch the query count on every page. A request that fires 200 queries is almost always one missing select_related/prefetch_related away from firing 3.
Serializer discipline in DRF
- Separate input and output serializers — validation rules and exposed fields are different concerns.
- Never expose a model with fields = '__all__'; name fields explicitly so a new sensitive column isn't auto-leaked.
- Push validation that needs the DB into validate() methods, and cross-field rules into the service layer.
- Use read_only / write_only to keep the contract honest.
Django rewards structure imposed early. App boundaries by domain, a service layer that owns business logic, explicit query optimisation, and disciplined serializers turn 'the framework that got slow and tangled' into one that stays fast and legible at scale.