Decomposing A Django Monolith
How I extracted two full-stack modules from a production Django app without downtime — and what I’d do differently next time.
The Problem
Our product had grown over several years into a classic Django monolith. One top-level app (auction) contained everything — models, views, APIs, templates, JS controllers, CSS files — all tangled together. A single change to the evaluations feature could break the resume viewer. CSS specificity wars were constant. New engineers took weeks to understand which files belonged to which feature.
The codebase looked something like this:
auction/
├── models.py # 3000+ lines, 40+ models
├── views.py # 2000+ lines
├── api.py # All Tastypie resources in one file
├── forms.py # Every form for every feature
├── static/
│ ├── js/ # 100+ controllers, all in one folder
│ └── css/ # One massive main.less importing everything
└── templates/
└── auction/ # 200+ templates, flat hierarchy
The task: extract the Evaluations module and the Resume Modal module into independent Django apps, each owning their own models, APIs, templates, JS, and CSS.
Why This Is Hard
Monolith decomposition sounds straightforward — “just move the files” — but in practice you hit a wall of invisible dependencies:
- Circular imports. Model A references Model B which imports a helper from Model A’s module.
- Template inheritance. Templates extend base layouts that assume certain CSS/JS is globally available.
- Shared state in JS. AngularJS controllers share scope variables across features.
- Foreign key migrations. Django migrations reference the old app label. Moving a model means rewriting migration history.
- CSS specificity. Moving a LESS file breaks the import order, changing which styles win.
- VCS history. A naive copy-delete loses all git/hg blame history.
The Methodology
I developed a phased approach (which I later documented internally for the team). Here’s the process:
Phase 1: Dependency Mapping
Before touching any code, I mapped every dependency of the target module:
# What I needed to answer for each file:
# 1. What does this file import FROM other modules?
# 2. What do other modules import FROM this file?
# 3. What templates does this include/extend?
# 4. What JS services does this controller depend on?
I used grep extensively:
# Find all imports of evaluation-related symbols
rg "from.*models import.*Evaluation" --type py
rg "evaluationService|evaluationCtrl" --type js
rg "evaluation" templates/ --include="*.html"
This produced a dependency graph. The key insight: you need to identify the cut points — the minimal set of interfaces between your module and the rest of the system.
Phase 2: Create the Target App Structure
# Create the new app with Django's startapp
python manage.py startapp evaluations auction/resume_modal/evaluations
# But we need more than just Python:
mkdir -p auction/resume_modal/evaluations/{static,html,migrations}
mkdir -p auction/resume_modal/evaluations/static/evaluations/{js,css}
mkdir -p auction/resume_modal/evaluations/html/evaluations
Phase 3: Move Python Code (Preserve History)
This is critical. Don’t copy-paste. Use your VCS’s move command:
# Mercurial
hg mv auction/models/evaluation.py auction/resume_modal/evaluations/models.py
# Git equivalent
git mv src/models/evaluation.py src/evaluations/models.py
This preserves blame history. Reviewers can still see who wrote each line and why.
The models after extraction:
# auction/resume_modal/evaluations/models.py
from django.db import models
from django.db.models import Q, Count, Avg
class EvaluationManagerMixin(object):
"""
Parent class for both the evaluation queryset and manager.
"""
def past_evaluations(self, opp, rec_ids, inner_facet, job_stage_round):
if not inner_facet:
inner_facet = opp.get_inner_facet()
stage_filter = Q(inner_facet=inner_facet)
if inner_facet >= common.choices.INNER_FACETS["CUSTOM_STAGES"]:
stage_filter = Q(stage__position=inner_facet)
return self.filter(
stage_filter, recruiter__id__in=rec_ids, opportunity=opp,
job_stage_round_id=job_stage_round
)
def pending_evaluations(self):
return self.filter(completed_at__isnull=True)
class EvaluationQuerySet(EvaluationManagerMixin, models.QuerySet):
pass
class EvaluationManager(EvaluationManagerMixin, models.Manager):
def get_queryset(self):
return EvaluationQuerySet(self.model, using=self._db)
Phase 4: Move JavaScript (Controllers + Services)
AngularJS modules had to be moved carefully because the controller registration order matters:
// Before: everything registered on one global module
angular.module('app')
.controller('EvaluationCtrl', function($scope, evaluationService) {
// 500 lines of evaluation logic
});
// After: controller lives in its own file under the evaluations app
// auction/resume_modal/evaluations/static/evaluations/js/evaluationCtrl.js
angular.module('app')
.controller('EvaluationCtrl', ['$scope', 'evaluationService',
function($scope, evaluationService) {
// Same logic, but now in the correct directory
}
]);
The key: update all <script> includes in the base template to reference the new paths.
Phase 5: Move CSS/LESS
This was the trickiest part. LESS files have import orders that affect specificity:
// Before: main.less imports everything
@import "evaluations.less";
@import "resume.less";
@import "events.less";
// ... 50 more imports
// After: each app owns its own CSS entry point
// auction/resume_modal/evaluations/static/evaluations/css/evaluations.less
@import "evaluation-cards.less";
@import "evaluation-form.less";
@import "evaluation-modal.less";
The main.less file then imports only the app-level entry point. This means each app controls its own internal CSS structure.
Phase 6: Move Templates
# Move with history
hg mv templates/auction/evaluation-cards.html \
auction/resume_modal/evaluations/html/evaluations/evaluation-cards.html
hg mv templates/auction/evaluation-form.html \
auction/resume_modal/evaluations/html/evaluations/evaluation-form.html
Update all {% include %} and {% extends %} references.
Phase 7: Handle Migrations
Django migrations reference app labels. When you move a model from auction to evaluations, existing migrations still reference auction.Evaluation. The solution:
# New initial migration in the evaluations app
class Migration(migrations.Migration):
initial = True
dependencies = [
('auction', '0XXX_last_migration_before_split'),
]
operations = [
# Don't CreateModel — the table already exists
# Instead, use SeparateDatabaseAndState
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name='Evaluation',
fields=[...],
),
],
database_operations=[], # Table already exists, no DB changes
),
]
Phase 8: Land in Phases
I did NOT ship this as one massive diff. Instead:
- D28249: Move Python code (models, resources, forms, urls, views)
- D28269: Move CSS code
- D28365: Move JS controllers and services
- D28367: Move HTML templates
- D28427: Final merge of the
code-separationbookmark into master
Each phase was independently reviewable and independently revertable.
The Result
After separation:
auction/resume_modal/evaluations/
├── models.py # Evaluation models + managers
├── helpers.py # Business logic
├── urls.py # App-scoped URLs
├── migrations/ # Clean migration chain
├── html/
│ ├── evaluations/ # Templates (old UI)
│ └── new-html/ # Templates (new UI)
├── static/
│ └── evaluations/
│ ├── js/ # Controllers + Services
│ └── css/ # LESS files
└── tests.py
Benefits realized:
- Faster code review: Evaluations changes now only touch evaluations files
- Clear ownership: New engineers know exactly where evaluation code lives
- Independent CSS: No more specificity wars with unrelated features
- Repeatable process: I documented the methodology, and the team used it for subsequent separations
What I’d Do Differently
- Automate the dependency scan. I did it manually with grep. A script that builds an import graph would save hours.
- Add integration tests first. Before moving anything, have a test that exercises the full flow. This catches breakages immediately.
- Move templates last. I moved them in the middle, which caused two rounds of fixups. Templates have the most invisible dependencies (template tags, context variables).
- Use a feature flag for the new paths. Instead of a big-bang switch, serve both old and new paths simultaneously during transition.
Takeaways
- Monolith decomposition is 80% dependency analysis, 20% moving files.
- Preserve VCS history or you lose the “why” behind every line.
- Land in phases. Reviewers can’t reason about a 5000-line diff.
- Document the process. The next person shouldn’t have to rediscover your methodology.
- The hardest bugs come from CSS import order and migration state, not Python imports.
This work took about 3 weeks of focused effort across 10+ revisions. The result was a clean module boundary that made the next 6 months of feature development measurably faster for the entire team.