--- title: "Decomposing A Django Monolith" slug: django-monolith-decomposition date: 2025-07-02 updated: 2025-07-02 tags: - code description: "How I extracted two full-stack modules from a production Django app without downtime — and what I'd do differently next time." status: published --- *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: 1. **Circular imports.** Model A references Model B which imports a helper from Model A's module. 2. **Template inheritance.** Templates extend base layouts that assume certain CSS/JS is globally available. 3. **Shared state in JS.** AngularJS controllers share scope variables across features. 4. **Foreign key migrations.** Django migrations reference the old app label. Moving a model means rewriting migration history. 5. **CSS specificity.** Moving a LESS file breaks the import order, changing which styles win. 6. **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: ```python # 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: ```bash # 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 ```bash # 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: ```bash # 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:** ```python # 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: ```javascript // 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 `