How to Structure a Django REST Framework Project
Before we write a single serializer, let's talk about how to set up a DRF project for the long haul. Tutorials tend to skip this part because it's "boring" — but the decisions you make here will haunt you (or serve you well) for years.
Installing and Configuring DRF
Installation is straightforward:
pip install djangorestframework
Then add it to INSTALLED_APPS in your settings:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
# ... other django apps
'rest_framework',
'your_app',
]
DRF also supports a REST_FRAMEWORK settings dictionary where you configure global defaults. Here's a production-sensible starting point:
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/day',
'user': '1000/day',
},
}
Understanding Each Configuration
In development, you probably want to add BrowsableAPIRenderer to DEFAULT_RENDERER_CLASSES so you get that nice HTML interface. In production, drop it for a cleaner, slightly faster JSON-only API.
# Development settings only
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.BrowsableAPIRenderer',
'rest_framework.renderers.JSONRenderer',
],
}
The Browsable API is a double-edged sword. For development and internal APIs, it's invaluable — you can test endpoints without leaving your browser, inspect response structure, and debug quickly. For public APIs, however, the extra HTML payload slows responses, and exposing the UI can reveal implementation details you'd prefer to keep hidden.
The most consequential choice is DEFAULT_PERMISSION_CLASSES. I recommend defaulting to IsAuthenticated globally and explicitly marking public endpoints with permission_classes = [AllowAny]. This is the "secure by default" pattern — if you forget to set permissions on an endpoint, it fails closed rather than open. That's the right failure mode.
Consider this scenario: you create a new endpoint to support a feature, test it thoroughly, and deploy. Two months later, you realize you forgot to add permission_classes. A client notices they can access user data they shouldn't see. With IsAuthenticated as the default, this can't happen — unauthenticated requests are rejected automatically. With AllowAny as the default, you have to remember to add authentication to every new endpoint, and that human memory will eventually fail you.
# Good: secure by default
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
# In your views, explicitly allow public access where intended
class PublicDataView(APIView):
permission_classes = [AllowAny] # Intentional and visible
# ...
App Structure That Scales
Here's the directory structure I'd recommend for a DRF project with any real complexity:
myproject/
├── config/
│ ├── settings/
│ │ ├── base.py
│ │ ├── development.py
│ │ └── production.py
│ ├── urls.py
│ └── wsgi.py
├── apps/
│ ├── users/
│ │ ├── models.py
│ │ ├── serializers.py
│ │ ├── views.py
│ │ ├── urls.py
│ │ ├── permissions.py
│ │ └── tests/
│ │ ├── test_views.py
│ │ └── test_serializers.py
│ └── articles/
│ ├── models.py
│ ├── serializers.py
│ ├── views.py
│ ├── urls.py
│ └── tests/
├── requirements/
│ ├── base.txt
│ ├── development.txt
│ └── production.txt
└── manage.py
Let's talk about why each of these choices matters, because structure without understanding is just cargo-culting.
Settings Separation: Base, Development, Production
Why split settings into base/development/production? Because the alternative is a single settings.py where you comment and uncomment things, or scatter if os.environ.get('ENV') == 'production': conditionals everywhere. The classic mistake this prevents: you're developing locally with DEBUG = True hardcoded, you deploy to production, and you forget to change it. Now your production server is handing detailed error pages — complete with stack traces and database credentials — to anyone who triggers a 500 error.
Separate settings files, with production.py importing from base.py and overriding the dangerous defaults, makes this class of mistake nearly impossible. Here's the pattern:
# config/settings/base.py
import os
DEBUG = False # Secure by default
ALLOWED_HOSTS = []
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-insecure-key-change-me')
# config/settings/development.py
from .base import *
DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
SECRET_KEY = 'insecure-dev-key-for-local-testing-only'
# config/settings/production.py
from .base import *
DEBUG = False
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')
SECRET_KEY = os.environ.get('SECRET_KEY') # Must be set in environment
# Ensure critical settings are provided
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable is required in production")
This structure also lets you add development-specific apps (like Django Debug Toolbar or django-extensions) without risking them in production, and you can use entirely different databases, caching backends, or logging configurations per environment.
Test Organization: Scalable Test Discovery
Why test directories instead of a single tests.py file? Because tests.py is a lie that works until it doesn't. Every project starts with a handful of tests. Six months later, tests.py is 3,000 lines and takes 45 seconds to scroll through. Splitting into test_views.py, test_serializers.py, test_permissions.py from the start means you never face a painful refactor.
More importantly, it makes it easy to run just the serializer tests (pytest apps/users/tests/test_serializers.py) when you're iterating on serializer logic, or to organize test files by feature:
apps/users/tests/
├── __init__.py
├── test_authentication.py # Auth-specific tests
├── test_serializers.py # Serializer validation tests
├── test_views.py # API endpoint tests
├── test_permissions.py # Permission logic tests
└── factories.py # Test data factories (see Testing section)
This structure also scales naturally with your application. A growing codebase that's organized from the start doesn't require painful refactoring; one that's disorganized becomes harder to navigate with each new test.
Custom Permissions: Domain Logic First
Why a permissions.py per app? Custom permissions are a natural part of each app's domain logic. A permissions.py file is a clean home for them, and it encourages you to think about permissions as first-class citizens rather than afterthoughts stuffed into views.py.
For example, in an articles app, you might have:
# apps/articles/permissions.py
from rest_framework import permissions
class IsAuthorOrReadOnly(permissions.BasePermission):
"""Allow authors to edit their own articles; everyone else can only read."""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions are only allowed to the author
return obj.author == request.user
class IsEditor(permissions.BasePermission):
"""Only editors can publish articles."""
def has_permission(self, request, view):
return request.user and request.user.groups.filter(name='editors').exists()
Then in your views, you compose these permissions clearly:
# apps/articles/views.py
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
def get_permissions(self):
if self.action == 'publish':
return [IsEditor()]
elif self.action in ['update', 'partial_update', 'destroy']:
return [IsAuthorOrReadOnly()]
return [permissions.AllowAny()]
This makes permission logic discoverable and testable in isolation.
The Central URL Configuration
In config/urls.py, use DRF's router to wire up ViewSets:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.users.views import UserViewSet
from apps.articles.views import ArticleViewSet, CommentViewSet
router = DefaultRouter()
router.register('users', UserViewSet, basename='user')
router.register('articles', ArticleViewSet, basename='article')
router.register('comments', CommentViewSet, basename='comment')
urlpatterns = [
path('api/v1/', include(router.urls)),
path('api/auth/', include('rest_framework.urls')),
]
How the Router Works
The DefaultRouter generates all the CRUD URLs for your ViewSets automatically. Here's a preview of what registering a ViewSet actually creates:
| HTTP Method | URL Pattern | ViewSet Action |
|---|---|---|
| GET | /api/v1/articles/ |
list |
| POST | /api/v1/articles/ |
create |
| GET | /api/v1/articles/{id}/ |
retrieve |
| PUT | /api/v1/articles/{id}/ |
update |
| PATCH | /api/v1/articles/{id}/ |
partial_update |
| DELETE | /api/v1/articles/{id}/ |
destroy |
The basename parameter is important: it's used to generate reverse URL names. basename='article' means you can use reverse('article-list') and reverse('article-detail', args=[pk]) in your code and tests. This is crucial for building hypermedia links and for testing — your tests don't break if you change the URL structure.
graph LR
A["DefaultRouter.register<br/>(prefix, viewset)"] --> B["Generate URL patterns"]
B --> C["GET /prefix/"]
B --> D["POST /prefix/"]
B --> E["GET /prefix/id/"]
B --> F["PUT /prefix/id/"]
B --> G["PATCH /prefix/id/"]
B --> H["DELETE /prefix/id/"]
style A fill:#e1f5ff
style B fill:#fff3e0
style C fill:#f3e5f5
style D fill:#f3e5f5
style E fill:#f3e5f5
style F fill:#f3e5f5
style G fill:#f3e5f5
style H fill:#f3e5f5
One important gotcha: if you have custom actions on your ViewSet (beyond the standard CRUD operations), you need to decorate them with @action:
from rest_framework.decorators import action
from rest_framework.response import Response
class ArticleViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
"""Custom action: publish an article."""
article = self.get_object()
article.published = True
article.save()
return Response({'status': 'article published'})
This generates an additional route: POST /api/v1/articles/{id}/publish/. Without the @action decorator, the router won't know about your custom method and won't create a URL for it.
An Alternative: Explicit URL Wiring
Not all projects use routers. Some prefer explicit urls.py files per app, especially if they have many custom endpoints or if the auto-generated URLs don't match their API design. Here's what that looks like:
# apps/articles/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.ArticleListCreateView.as_view(), name='article-list'),
path('<int:pk>/', views.ArticleDetailView.as_view(), name='article-detail'),
path('<int:pk>/publish/', views.ArticlePublishView.as_view(), name='article-publish'),
]
# config/urls.py
from django.urls import path, include
urlpatterns = [
path('api/v1/articles/', include('apps.articles.urls')),
path('api/v1/users/', include('apps.users.urls')),
]
This approach is more verbose but gives you explicit control. Choose based on your needs: routers are faster for standard CRUD APIs; explicit URLs are clearer for complex, custom-heavy APIs. Many projects use routers for 80% of their endpoints and explicit views for the remaining 20%.
With your project structure established, you're ready to start building the components that make your API actually work. Everything starts with serializers.
Only visible to you
Sign in to take notes.