A 7-year-old Django monolith is usually carrying years of business knowledge, not just technical debt. The goal isn't to "rewrite the monolith." It's to make change safer while gradually improving the architecture.
A practical approach is to optimize for reducing the cost of future changes, not maximizing architectural purity.
1. Learn where the system actually hurts
Before changing architecture, spend a few weeks collecting information:
- Which apps change every sprint?
- Which files create merge conflicts?
- Which bugs keep recurring?
- Which views are slow?
- Which deployments are risky?
- Which parts have no tests?
You'll often discover that only 15–20% of the codebase causes most of the pain.
2. Draw the architecture you actually have
Create a living document showing:
- Django apps
- external APIs
- databases
- Celery tasks
- cron jobs
- management commands
- signal usage
- authentication flow
Also identify hidden coupling like:
Order save()
↓
post_save signal
↓
Inventory updates
↓
Email
↓
Analytics
↓
Webhook
Those invisible chains are common sources of production surprises.
3. Add characterization tests first
Don't start by rewriting code.
Instead, write tests that describe current behavior—even if the behavior is odd.
Example:
def test_order_discount_rounding():
...
The purpose is:
"This is what production does today."
That gives you confidence to refactor later.
4. Stop adding to the mess
Agree on one rule with the team:
New code follows the new structure.
Even if old code stays messy.
For example:
Instead of:
views.py
doing everything:
View
↓
Service
↓
Repository/ORM
Existing code stays untouched until it needs modification.
5. Introduce service layer gradually
Many old Django apps have business logic scattered across:
- models
- views
- serializers
- forms
- signals
Move new business logic into explicit services.
Instead of:
class Order(models.Model):
def approve(self):
...
Use:
class OrderApprovalService:
def approve(order):
...
Business logic becomes easier to test and reuse.
6. Kill magic
Old Django projects often rely heavily on:
- signals
- overridden save()
- overridden delete()
- middleware side effects
- global state
Magic makes production debugging painful.
Prefer explicit calls:
Instead of
save()
↓
signal
↓
email
Use
service.approve()
↓
save
↓
send email
The control flow becomes obvious.
7. Put tests around critical paths
You don't need 100% coverage.
Start with:
- login
- checkout
- payments
- user registration
- billing
- permissions
Those are the expensive production failures.
8. Introduce feature flags
Never merge risky refactors directly.
Instead:
Old implementation
│
Feature flag
├── Old
└── New
Roll out gradually:
- internal users
- 5%
- 20%
- 100%
9. Measure production
You can't improve what you can't see.
Useful metrics include:
- request latency
- error rate
- SQL query count
- slow queries
- Celery failures
- deployment frequency
- mean time to recovery (MTTR)
If a refactor improves none of these, reconsider whether it was worth doing.
10. Refactor by business capability, not by file
Instead of saying:
"Let's clean models.py"
say:
"Let's improve invoicing."
Move one capability into cleaner boundaries while leaving unrelated areas alone.
This produces visible business value every sprint.
11. Reduce dependency direction
Older monoliths often look like:
Users
↓
Orders
↓
Payments
↓
Inventory
↓
Users
Circular dependencies make changes risky.
Aim for one-way dependencies:
Views
↓
Services
↓
Models
↓
Database
12. Keep deployments boring
If a refactor changes behavior and structure simultaneously, it's difficult to isolate failures.
Separate work into:
- structural changes with no behavior change
- behavior changes after the structure is in place
Small pull requests (roughly a few hundred lines) are also easier to review and roll back than massive refactors.
A phased roadmap
Month 1
- Add logging and observability.
- Map the architecture.
- Identify the highest-risk workflows.
- Begin characterization tests.
Months 2–3
- Introduce a service layer for new work.
- Replace implicit behavior (especially signals) with explicit orchestration where practical.
- Add feature flags for larger changes.
Months 4–6
- Refactor one business domain at a time.
- Reduce circular dependencies.
- Increase automated test coverage around the most frequently changed areas.
- Continue shipping features while improving the code incrementally.
The key is that users shouldn't notice this work except through increased reliability.
One principle has held up well across many long-lived systems:
Leave every piece of code a little better than you found it, and avoid large rewrites until the architecture naturally supports them.
That approach minimizes the risk of weekly production regressions while steadily making the monolith easier to understand and evolve.