DEV Community

Cover image for Structuring a Django REST Framework API Project: How Do I Go About It?
Wesley E. MONTCHO
Wesley E. MONTCHO

Posted on

Structuring a Django REST Framework API Project: How Do I Go About It?

This is my first technical blog post. More will follow on Django, architecture, and things I learned the hard way. Discussion is very welcome.


A few weeks ago, a senior Django developer (more experienced than me πŸ˜…) joined a project I had been building solo. After reviewing the codebase, he sent me this message (translated and paraphrased from French):

"There are quite a few things that are not well finished. Some deficiencies. But there are also some very beautiful things that allowed me to move fast."

That last sentence is why I told myself: why not start writing, both to preserve what worked and to connect with others? So here I am. πŸ‘‹

Properly structuring a project, especially a large one, is crucial. It's about making decisions early that let you, and others, navigate confidently, add features safely, and hand the project off without needing to be called back every five minutes.

Let's get into it. πŸš€


The Problem With Django's Default Layout

When you run django-admin startproject backend, you get this:

backend/
β”œβ”€β”€ backend/
β”‚   β”œβ”€β”€ settings.py
β”‚   β”œβ”€β”€ urls.py
β”‚   β”œβ”€β”€ wsgi.py
β”‚   └── asgi.py
β”œβ”€β”€ manage.py
Enter fullscreen mode Exit fullscreen mode

Then you add your first app:

python manage.py startapp myapp
Enter fullscreen mode Exit fullscreen mode

And suddenly:

backend/
β”œβ”€β”€ myapp/
β”‚   β”œβ”€β”€ models.py
β”‚   β”œβ”€β”€ views.py
β”‚   β”œβ”€β”€ admin.py
β”‚   └── tests.py
β”œβ”€β”€ backend/
β”‚   └── settings.py
Enter fullscreen mode Exit fullscreen mode

This works perfectly. For a tutorial. 😬

In a real production context, models.py quickly hits 1,000+ lines. views.py becomes a novel. settings.py mixes database config, email config, AWS config, etc.

The structure that scales is not the one you start with. It's the one you design from day one.

Let's fix that.


1. The Top-Level Split: apps/ and backend/

The first and most impactful decision: separate your Django apps from your project configuration.

my-project/
β”œβ”€β”€ backend/           ← Django project config (settings, urls, celery…)
β”œβ”€β”€ apps/              ← All your Django apps live here
β”œβ”€β”€ docs/              ← Documentation
β”œβ”€β”€ scripts/           ← Utility scripts
β”œβ”€β”€ tests/             ← Test suite
β”œβ”€β”€ manage.py
└── requirements.txt
Enter fullscreen mode Exit fullscreen mode

Any developer opening this repo for the first time immediately understands the map:

  • backend/ β†’ framework config, don't touch without a reason
  • apps/ β†’ this is where the features and business logics are implemented

Simple. Obvious. Zero ambiguity. That's the goal.


2. The backend/ Folder: Config That Breathes

Still with me? Good. πŸ‘Œ

The most common mistake inside backend/ is keeping a single monolithic settings.py. JWT, email, AWS, rate limiting: all in one file becomes a wall with no structure.

Split settings by domain:

backend/
β”œβ”€β”€ settings/
β”‚   β”œβ”€β”€ __init__.py      ← Loads sub-modules
β”‚   β”œβ”€β”€ base.py          ← Core Django config (installed apps, middleware, DB…)
β”‚   β”œβ”€β”€ smtp.py          ← Email config only
β”‚   └── configs/
β”‚       └── storages.py  ← S3/media storage config
β”œβ”€β”€ urls.py              ← Root URL dispatcher
β”œβ”€β”€ celery.py            ← Celery app init (if using async tasks)
β”œβ”€β”€ asgi.py
└── wsgi.py
Enter fullscreen mode Exit fullscreen mode
# backend/settings/smtp.py: email concerns only
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = os.environ.get("EMAIL_HOST")
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
EMAIL_USE_TLS = True

# backend/settings/configs/storages.py: storage concerns only
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_BUCKET_NAME")
Enter fullscreen mode Exit fullscreen mode

When a DevOps engineer needs to update email config, they go directly to smtp.py. They don't have to read 400 lines of unrelated settings. This is respect for the next developer's time. πŸ™


3. The apps/ Folder: One Domain, One App

Every feature domain gets its own Django app. No monolithic core/ app that holds everything.

apps/
β”œβ”€β”€ authentication/    ← Registration, login, JWT…
β”œβ”€β”€ transactions/      ← Transactions, refunds…
β”œβ”€β”€ users/             ← User model, profiles…
β”œβ”€β”€ notifications/     ← Email, SMS, Push, WhatsApp…
β”œβ”€β”€ marketing/         ← Referrals, contact messages…
β”œβ”€β”€ settings/          ← System and per-user settings
└── utils/             ← Shared code (base model, enums, errors, middleware)
Enter fullscreen mode Exit fullscreen mode

Each app is a bounded context: it owns its models, its views, its serializers, its business logic. If tomorrow you extract notifications/ into a separate microservice, the limits are already drawn.

The rule I apply: if you can describe it in one domain noun, it's an app. Authentication, Transactions, Notifications. Not Stuff, not Core, not Api.


4. Inside an App: Every File Has a Job

Still following? πŸ‘€ This is where most Django projects diverge from best practices.

The generated models.py / views.py monolith pattern doesn't scale. Here's what a well-structured app looks like instead:

apps/transactions/
β”œβ”€β”€ models/
β”‚   β”œβ”€β”€ __init__.py          ← Star-imports all model files
β”‚   β”œβ”€β”€ transactions.py
β”‚   β”œβ”€β”€ refunds.py
β”‚   └── …
β”œβ”€β”€ serializers/
β”‚   β”œβ”€β”€ transactions.py
β”‚   β”œβ”€β”€ refunds.py
β”‚   └── …
β”œβ”€β”€ views/
β”‚   β”œβ”€β”€ transactions.py
β”‚   β”œβ”€β”€ refunds.py
β”‚   └── …
β”œβ”€β”€ services/
β”‚   β”œβ”€β”€ transaction_processor.py
β”‚   └── refund_service.py
β”œβ”€β”€ admin/
β”‚   β”œβ”€β”€ __init__.py
β”‚   └── transactions.py
β”œβ”€β”€ router.py
β”œβ”€β”€ urls.py
β”œβ”€β”€ tasks.py     ← Celery tasks (can be split too)
β”œβ”€β”€ apps.py
└── tests.py
Enter fullscreen mode Exit fullscreen mode

Split models into sub-files

The models/__init__.py re-exports everything via star imports:

# apps/transactions/models/__init__.py
from apps.transactions.models.transactions import *
from apps.transactions.models.refunds import *
Enter fullscreen mode Exit fullscreen mode

From Django's perspective, nothing changes: apps.transactions.Transaction still works. From a developer's perspective, everything changes:

  • Each model file stays focused and readable (< 300 lines)
  • Git merge conflicts are rare and simple to resolve when they happen
  • Searching "transactions" in your file list returns models, services, serializers, all at once

The services/ layer

Views should not contain business logic. This isn't a rule I invented. It's a principle that saves you every time you need to reuse logic in a task, a management command, or a test.

# ❌ Business logic in the view β€” classic antipattern
class TransactionViewSet(ModelViewSet):
    def create(self, request):
        dest_user = User.objects.get(...)
        if not dest_user.is_active:
            raise ValidationError("User not available")
        # ...50 more lines of business logic...

# βœ… The view delegates to a service
class TransactionViewSet(ModelViewSet):
    def create(self, request):
        serializer = CreateTransactionSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        transaction = TransactionProcessor.create_transaction(
            user=request.user,
            **serializer.validated_data
        )
        return Response(TransactionSerializer(transaction).data)
Enter fullscreen mode Exit fullscreen mode

TransactionProcessor.create_transaction() can now be called from a view, a task, a test, or a management command, without changing a single line. πŸ’ͺ


5. The Base Model: Your Foundation for Every Table

Every model in this project inherits from one class. Just one.

# apps/utils/models/base_model.py
from uuid import uuid4
from django.db import models
from django_softdelete.models import SoftDeleteModel

class AbstractCommonBaseModel(SoftDeleteModel):
    uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
    active = models.BooleanField(default=True)
    timestamp = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True
Enter fullscreen mode Exit fullscreen mode

Every model then uses it:

from apps.utils.models.base_model import AbstractCommonBaseModel

class Transaction(AbstractCommonBaseModel):
    # uuid, active, timestamp, updated β†’ already there for free
    status = models.CharField(...)
    amount = models.DecimalField(...)
Enter fullscreen mode Exit fullscreen mode

Pure DRY. You get for free, on every table:

Field Benefit
uuid (PK) No sequential IDs leaking record counts; safe in URLs
active Logical on/off switch
timestamp Creation date, always available
updated Last modification date, always available
Soft delete .delete() marks the row deleted, doesn't destroy it

Soft delete, specifically, has become a habit over the years. You never permanently remove a sensitive record. Even if a user "deletes" something from their view, the row stays for audits, disputes, and reconciliation. πŸ”

Defining this in the base model means you can never forget it. The pattern is enforced at the class hierarchy level.


6. The utils/ App: Your Project's Internal Library

Every project accumulates shared code. The question is whether it lives scattered everywhere or in one dedicated place.

apps/utils/
β”œβ”€β”€ models/
β”‚   └── base_model.py           ← AbstractCommonBaseModel
β”œβ”€β”€ xlib/                       ← "extended library" (your project's vocabulary)
β”‚   β”œβ”€β”€ enums/
β”‚   β”‚   β”œβ”€β”€ base.py             ← BaseEnum
β”‚   β”‚   β”œβ”€β”€ errors.py           ← All error codes
β”‚   β”‚   β”œβ”€β”€ transactions.py     ← TransactionStatusEnum, etc.
β”‚   β”‚   └── users.py            ← UserStatusEnum, etc.
β”‚   β”œβ”€β”€ error_util.py           ← Centralized error resolution
β”‚   β”œβ”€β”€ exceptions.py           ← Custom domain exceptions
β”‚   └── validators.py           ← Custom validators
β”œβ”€β”€ middleware/
β”‚   └── …
β”œβ”€β”€ commons/
β”‚   └── mixins.py               ← Reusable ViewSet mixins
└── views/
    └── errors.py               ← Public error map endpoint
Enter fullscreen mode Exit fullscreen mode

One pattern worth highlighting: the Enum system. Instead of scattering verbose choice tuples across model files:

# ❌ The old way, repeated everywhere
status = models.CharField(choices=[('PENDING', 'Pending'), ('FAILED', 'Failed'), ...])

# βœ… One enum, reused everywhere
from apps.utils.xlib.enums import TransactionStatusEnum

status = models.CharField(
    choices=TransactionStatusEnum.items(),
    default=TransactionStatusEnum.PENDING.value,
)
Enter fullscreen mode Exit fullscreen mode

One source of truth. No copy-paste drift. 🎯


7. URL Routing: Three Layers, Clear Contracts

Every app follows the same three-layer URL pattern. No exceptions.

Layer 1 (router.py): Register ViewSets

# apps/transactions/router.py
from rest_framework.routers import DefaultRouter
from apps.transactions.views import TransactionViewSet, RefundViewSet

router = DefaultRouter()
router.register(r'refunds', RefundViewSet, basename='refunds')
router.register(r'', TransactionViewSet, basename='transactions')

urls_patterns = router.urls
Enter fullscreen mode Exit fullscreen mode

Layer 2 (urls.py): App URL contract (router URLs + any manual path() entries)

# apps/transactions/urls.py
from apps.transactions.router import urls_patterns

urlpatterns = urls_patterns
Enter fullscreen mode Exit fullscreen mode

Layer 3 (backend/urls.py): Root dispatcher, versioned

urlpatterns = [
    path("v1/auth/",          include("apps.authentication.urls")),
    path("v1/transactions/",  include("apps.transactions.urls")),
    path("v1/users/",         include("apps.users.urls")),
    # …
]
Enter fullscreen mode Exit fullscreen mode

A new developer looking for "where does the transaction list endpoint come from" follows this path in under 60 seconds. No magic. No hunting. πŸ—ΊοΈ

When you need /v2/, you add a second block without touching the first.


Bonus: scripts/ and docs/

Small things that signal a professional codebase:

scripts/
β”œβ”€β”€ rmpycaches.py        ← Clear all __pycache__ folders
└── rmmigrationfiles.py  ← Remove migration files (for dev resets)

docs/
β”œβ”€β”€ modelings/           ← (For example ) Use cases, class diagrams, sequence diagrams
Enter fullscreen mode Exit fullscreen mode

The Real Impact

We're almost done, and this last part is the reason any of this matters. 🏁

The senior developer who reviewed my codebase moved fast. He could navigate without asking me where things were. That's the metric that matters: not "is the code clever" but "can the next person read it?"

During development:

  • You never wonder "where should I put this?". The structure answers.
  • Features are isolated: adding marketing/ doesn't touch transactions/
  • The base model prevents forgotten patterns (timestamps, soft delete)

During code review:

  • PRs stay small because changes live in one app's files
  • Reviewers know exactly where to look

During onboarding:

  • A new developer maps the codebase in 20 minutes by reading folder names
  • backend/urls.py is the full table of contents for the API

During incidents:

  • Bug in transaction creation? Check services/transaction_processor.py, not a 1,000-line views.py

During refactoring:

  • Extracting notifications/ into a microservice means moving one folder, not grepping scattered code

Takeaway Checklist

For your next Django project, or to refactor your current one:

  • [ ] Name your config folder backend/ (or config/), not after your project
  • [ ] Put all apps under apps/
  • [ ] Split settings.py by domain: smtp.py, storages.py, etc.
  • [ ] Create AbstractCommonBaseModel with UUID PK, timestamps, and soft delete
  • [ ] Split models.py into models/<domain>.py files, re-exported via __init__.py
  • [ ] Add a services/ layer: views call services, not raw ORM
  • [ ] Create apps/utils/ as your project's internal library
  • [ ] Use enums for model choices (one source of truth)
  • [ ] Follow the three-layer URL pattern: router.py β†’ urls.py β†’ backend/urls.py
  • [ ] Add a scripts/ folder for repetitive dev commands
  • [ ] Write at least a minimal docs/. Your future self will thank you.

What's Next

Next post: Standardized error handling in Django REST Framework, with i18n support and a React integration example.

Every API returns errors. The question is whether they're consistent, translatable, and useful to the frontend consuming them. I'll show you the ErrorEnum + ERROR_MAP pattern, how drf-standardized-errors shapes every response, and how to wire it up so a React app displays the right message in the right language, without any if/else chains.


If you spotted something I got wrong, or if you do it differently and it works better, comment below. I'm here to learn as much as to share. 🀝

Top comments (0)