# ADR 0018 — Python 3.12 + Django 5 + Django REST Framework as the backend language and framework | | | |---|---| | **Status** | **Accepted** — 2026-07-30 | | **Scope** | Backend language, web framework, API layer, database driver, schema-generation tooling, and where async is used. Does **not** cover runtime architecture (ADR 0001), the database engine (ADR 0002), the frontend (ADR 0013) or migration authority (ADR 0017) | | **Owner** | Talha Ahmed (senior). Ahmed Mujtaba's onboarding path — the DRF viewset/serializer pattern, the generated TypeScript client, the Django admin registration for the seven controlled vocabularies — is explicitly part of the consequences below | | **Consistent with** | `_decisions.md` Part 1 → *Backend language and framework*, *Testing, CI and delivery process* | | **Related ADRs** | 0001 (this stack runs as a modular monolith with a separate worker), 0002 (PostgreSQL 16 + psycopg3), 0013 (the SPA this serves), 0017 (the ORM maps to the schema; it never generates it) | **Why this ADR exists.** The decision was recorded in `_decisions.md` Part 1 and *stated* in ADR 0001's Decision section, but ADR 0001 argues monolith-versus-services, not language. The two options that were genuinely close — FastAPI + SQLAlchemy, and Node/NestJS — had their analysis recorded nowhere durable. This is the decision a new reviewer questions first, and under `07`'s R-04 (bus factor of one) an undocumented framework choice is exactly the kind of reasoning that exists only in one person's head. This ADR closes that gap. It changes nothing. --- ## Context ### The repository imposes no constraint Verified absent (`_repo-findings.md` §B): `package.json` and any lockfile, `node_modules/`, `requirements.txt`, `pyproject.toml`, `Pipfile`, `composer.json`, `go.mod`, `Gemfile`, `pom.xml`, `build.gradle`, any `Dockerfile`, any API route, controller, service or server-side code of any kind. What exists is a static browser-only prototype with zero network calls anywhere in `js/` or `index.html` (§C). Operating Rule 9 ("do not replace the existing stack") therefore constrains **only** the frontend and design system. There is no backend stack to preserve, so this is a greenfield choice — and one that must not be made by familiarity, because there is nothing to be familiar with. One piece of weak evidence must be explicitly discounted: `.gitignore` contains a Python section and `.env` rules but no Node section. Per `_repo-findings.md` §H this **postdates `devserver.py`** (added for local preview only) and is ambiguous evidence of intent. It is not a decision input. ### What actually decides it: the two Phase 1 workloads The choice is determined by the work Phase 1 has to do, not by preference: | Workload | What it needs | Ecosystem reality | |---|---|---| | **CV parsing** — the primary Phase 1 intake, alongside Outlook mail | PDF text and layout extraction, DOCX extraction, OCR for scanned CVs, a fallback for odd formats | `pypdf`/`pdfplumber`, PyMuPDF, `python-docx`/mammoth, `pytesseract`/OCRmyPDF, `unstructured`. This ecosystem is **decisively** Python. In Node or .NET the same job means shelling out to Python or buying a SaaS parser — and a SaaS parser sends candidate CVs to a third party, which BRD §7.4 forbids | | **ATS scoring, dedupe and fairness evaluation** — Talha's work | Provider SDKs, fuzzy string scoring, embeddings, statistical evaluation | `rapidfuzz` for the six duplicate signals (ADR 0008), `pgvector` for Phase 2 embeddings (ADR 0006), `pandas`/`scipy` for the disparate-impact evaluation BRD §7.2 requires | | Microsoft Graph mail polling (ADR 0005) | A maintained SDK; I/O-light at 200–600 documents/day | Maintained Python SDK exists. Not a differentiator — every candidate language has one | The parsing row is the load-bearing one, and it is the reason the language question is settled before the framework question. **The parsing ecosystem cannot be worked around without either a subprocess boundary to Python or a compliance violation.** ### What the framework has to supply, given the team Two developers, one junior, no ops staff, 25 modules, and a one-month-to-first-slice expectation. Anything the framework does not supply is a month of platform work this team cannot spend: - **Migrations.** None exist to inherit (§B); some ordered, ledgered mechanism is mandatory from Phase 0 week 1. - **An internal back-office for the seven controlled vocabularies** (BRD §9.2), which are hardcoded arrays in `js/data.js` today. Without a free admin, a Settings UI enters Phase 1 scope. - **A real permission framework.** Today's RBAC gates nothing: the matrix is a display widget, clicking a cell mutates an in-memory array, and there is no `can()` anywhere (`js/rbac.js:78`, `js/rbac.js:83-85`). Security settings are inert chrome (`js/settings.js:148-154`). - **Session and CSRF hardening**, likewise absent. - **Versioned JSON APIs with a generated schema**, required by BRD §8.3. --- ## Options considered | # | Option | Pros (at their strongest) | Cons | |---|---|---|---| | **A** | **Python 3.12 + Django 5 + DRF** *(chosen)* | Parsing and AI ecosystems are native, no subprocess boundary. Migrations, admin, auth, permissions, sessions and CSRF all ship with the framework, so the team writes product code in week 1 instead of platform code. DRF plus `drf-spectacular` gives versioned APIs and a generated OpenAPI schema, satisfying BRD §8.3. The admin removes a Settings UI from Phase 1 scope outright. One `iam.can()` chokepoint is easy to reach for when the framework already has a permission layer | The ORM is poor at the analytics queries BRD §5.4 wants, so those go to raw SQL behind read-model views. Async support is weaker than FastAPI's. Django's migration idioms must be deliberately constrained (ADR 0017), which is real friction. More framework to learn than FastAPI before a junior's first endpoint | | **B** | **Python 3.12 + FastAPI + SQLAlchemy 2 + Alembic** | Best-in-class async, a genuinely nicer ORM, Pydantic validation as a first-class citizen, automatic OpenAPI with no add-on, and a smaller surface for a junior to hold in their head. Keeps the entire parsing and AI ecosystem | The team then hand-builds authentication, the permission framework, an admin back-office and the migration workflow — roughly a month of platform work, which is the whole first-slice budget. With no admin, the seven controlled vocabularies need a Phase 1 Settings UI. The async advantage buys little here: every long operation is already queued to the worker (ADR 0004), so the web tier waits on nothing but the one streaming endpoint | | **C** | **Node/TypeScript (NestJS), sharing one language with the frontend** | **Genuinely attractive and the closest call.** One language across the stack, one dependency toolchain, one mental model, shared DTO types with no code generation, and a junior context-switching less. NestJS supplies DI, validation and a module structure that maps well onto the module boundaries of ADR 0001 | The parsing gap is decisive: PDF layout extraction, DOCX and especially OCR have no Node equivalent of comparable quality, so Phase 1's primary intake path becomes a Python subprocess (two runtimes, two dependency sets, two deploy artefacts for two developers) or a third-party parser (a BRD §7.4 compliance stop). The fairness evaluation has the same problem. And Talha's leverage — scoring, matching, AI — is Python-side; choosing Node spends the senior's advantage to save the junior a context switch. **Mitigated rather than accepted:** the TypeScript client is *generated* from the OpenAPI schema, so the API contract is written once and the type-sharing benefit is largely recovered | | **D** | **.NET (ASP.NET Core) or Java/Spring Boot** | Strong, mature platforms with excellent tooling, first-class async, and real enterprise-grade auth stories. If Utopia Brands IT already standardised on either, the operational argument would be substantial | Wrong ecosystem for parsing and AI — the same subprocess-or-SaaS problem as Option C, without Option C's language-sharing upside. Heavier ceremony per feature than two developers can absorb across 25 modules. No evidence in the repository or the brief of an existing .NET/JVM standard, so the one argument that could carry this option is unavailable | | **E** | **Django templates + HTMX instead of DRF + SPA** | Radically less code: no API layer, no client state, no generated client, no second build. For an internal tool at 66 seats this is a serious and often correct answer | Rejected by ADR 0013, not here. The design system in `css/styles.css` (1269 lines, WCAG AA verified across 23 routes × 2 themes) and the interaction patterns the prototype already validates assume a client-rendered app; and the versioned requisition editor, scorecards and offer approvals are exactly the forms where server round-trips per interaction hurt most. Also loses the versioned JSON API BRD §8.3 requires, which would have to be built alongside the HTMX views anyway | | **F** | **Python + Django, but Django templates for internal screens and DRF only for the API** | Would let the admin-adjacent screens ship faster while keeping the API | Two rendering models in one codebase for a two-person team, and the boundary between "internal screen" and "product screen" is not stable — every screen eventually wants both. Rejected as a false economy | --- ## Decision **Option A.** Concretely: | Element | Choice | Note | |---|---|---| | Language | **Python 3.12** | Chosen by the parsing and AI ecosystems, not by the framework | | Web framework | **Django 5** | For migrations, admin, auth, permissions, sessions and CSRF — team-shape reasons, not taste | | API layer | **Django REST Framework** | Versioned JSON APIs under `/api/v1/` (see `06-api-boundaries.md`) | | Schema generation | **`drf-spectacular`** | Generates the OpenAPI schema; the frontend's TypeScript client is generated from it, so the contract is authored once | | Database driver | **psycopg3** | With PostgreSQL 16 per ADR 0002 | | Async | **Only where it earns its keep** | The model-streaming endpoint is a Django async view under uvicorn. Everything else is sync, because every long operation is already queued to the worker | | ORM's role | **Mapper only** | Per ADR 0017: plain SQL is the schema authority; the ORM never generates it. Analytics uses raw SQL behind read-model views | ### The tradeoffs being accepted explicitly 1. **Django's ORM is poor at the analytics queries in BRD §5.4.** Accepted: analytics uses raw SQL behind read-model views declared in migrations, which is already the one sanctioned exception to the "no module reads another module's tables" rule (ADR 0001 rule 2). 2. **Django's async story is weaker than FastAPI's.** Accepted: the worker absorbs all long work (ADR 0004), so the web tier never waits on a parse or a model call except in the single streaming endpoint, which is an async view. 3. **Django's migration idioms must be deliberately constrained.** Accepted and separately ruled on in ADR 0017. This is the sharpest cost of the choice: the framework is selected partly *because* migrations ship with it, and then its autogeneration is switched off. The reason is that this design's invariants live in objects no ORM expresses, and the ledger plus ordering is the part worth having. 4. **More framework surface for the junior than FastAPI.** Accepted and mitigated: Ahmed's first backend tasks are DRF viewsets and serializers against facades Talha has already shipped, which is the narrowest possible slice of Django to learn first. --- ## Justification The decision reduces to one question with an unavoidable answer and one question with a team-dependent answer. **Language: unavoidable.** Phase 1's primary intake is CV files. High-quality PDF layout extraction, DOCX extraction and OCR exist in Python and effectively nowhere else at comparable quality. Every non-Python option resolves to a Python subprocess (two runtimes for two developers) or a third-party parser (candidate CVs leaving controlled infrastructure, which BRD §7.4 forbids). The fairness evaluation BRD §7.2 requires lands in the same place. This is not a close call and should not be re-litigated on aesthetic grounds. **Framework: decided by team shape.** FastAPI is the better framework in the abstract and would be the right answer for a larger team. For *this* team the comparison is not "which framework is nicer" but "which framework means we write product code in week 1". Django supplies migrations, an admin, auth, a permission framework and session/CSRF hardening — five things that are otherwise a month of platform work out of a one-month first-slice budget, and two of which (permissions, session hardening) exist today only as inert UI chrome that gates nothing (`js/rbac.js:78`, `js/settings.js:148-154`). The admin alone removes a Phase 1 Settings UI for the seven controlled vocabularies from scope. **Why Node was the closest call and still lost.** One language across the stack is a real benefit, particularly for a junior. But it would be bought by putting the primary intake path behind a subprocess boundary and by spending the senior developer's Python leverage in scoring, matching and AI — trading the strength of the person who owns the critical path for a convenience for the person who does not. The type-sharing benefit, which is the concrete half of the argument, is recovered instead by generating the TypeScript client from the OpenAPI schema. --- ## Consequences ### Positive - No subprocess boundary and no third-party parser in the CV path; parsing runs in the worker process the architecture already has. - Migrations, admin, auth, permissions, sessions and CSRF are framework-supplied from day one. - The seven controlled vocabularies get a back-office for free, keeping a Settings UI out of Phase 1. - One `iam.can()` chokepoint sits naturally on top of a permission layer the framework already has (ADR 0009). - `drf-spectacular` makes the API contract a generated artefact, so the frontend client is generated rather than hand-maintained — the mitigation that makes losing Option C's language sharing affordable. - A junior's first backend task is a viewset and a serializer against a shipped facade: small, varied, independently demonstrable, and reviewable. ### Negative — the costs being accepted - Two languages in the repository (Python backend, TypeScript frontend) with two toolchains and two dependency sets, plus a generated client in between. - Django's autogeneration is deliberately disabled (ADR 0017), so the framework's most-advertised convenience is not used, and every migration costs four steps instead of one. - Analytics queries leave the ORM for raw SQL, which needs its own review discipline. - The ORM is a mapper over a hand-written schema, so hand-written models must be kept true — an ongoing obligation ADR 0017's model-vs-state gate enforces. - More framework for the junior to learn before their first endpoint than FastAPI would require. --- ## Risks | # | Risk | Likelihood | Impact | Mitigation | |---|---|---|---|---| | R1 | The team fights Django's migration idioms every sprint, having chosen Django partly for migrations | Medium | Medium | ADR 0017 rules on the mechanism once, with a template and a gate-ownership table, so it is a policy rather than a per-PR argument | | R2 | The ORM is reached for on the analytics queries it is bad at, producing slow N+1 dashboards | Medium | Medium | Analytics is a module owning read-only SQL views declared in migrations (ADR 0001 rule 2); dashboard queries are reviewed against the views, not written as ORM chains | | R3 | Django admin is exposed more widely than intended and becomes a de facto unaudited write path around `iam.can()` | Medium | **High** | Admin is restricted to the reference-data models and to `hr_admin`; domain tables are not registered. Admin writes go through the same trigger-written history and audit path as any other write, so they cannot be silent (ADR 0009) | | R4 | A junior's Django learning curve slows the first slice | Medium | Low | Ahmed's Phase 0 work (the XSS pass, the React shell) is entirely frontend and independent of the backend; his first backend tasks are viewsets over shipped facades | | R5 | The one async streaming endpoint under uvicorn behaves differently from the sync majority and becomes a source of subtle bugs | Low | Medium | It is one endpoint with one job, it touches no domain write path, and the circuit breaker around AI (ADR 0011) already covers its failure mode | | R6 | Python 3.12 / Django 5 version support windows expire mid-project | Low | Low | Both are current with multi-year support; the upgrade path is routine and belongs in the maintenance runbook | --- ## Revisit conditions Reopen this ADR if any of the following becomes true: 1. CV parsing is moved to a vendor with an acceptable data-processing agreement and a BRD §7.4 sign-off — which removes the single decisive argument for Python. 2. Utopia Brands IT mandates a corporate backend standard (.NET or JVM), which would make Option D's operational argument real rather than hypothetical. 3. The team grows past roughly four backend developers, at which point the platform work Option B requires stops being a month of the budget. 4. The web tier acquires genuinely concurrent, long-lived connection workloads that the worker cannot absorb — the only scenario in which FastAPI's async advantage becomes load-bearing. --- ## Related - `adr/0001-modular-monolith-versus-microservices.md` — the runtime architecture this stack runs as; states the choice, argues the monolith rather than the language. - `adr/0002-primary-relational-database.md` — PostgreSQL 16 and psycopg3. - `adr/0013-frontend-strangler-migration.md` — the SPA this backend serves, and the rejection of the templates-plus-HTMX alternative (Option E here). - `adr/0016-real-postgres-in-ci.md` — the CI pipeline this stack is tested in. - `adr/0017-plain-sql-migrations-as-schema-authority.md` — how Django's migration framework is constrained, which is this decision's sharpest cost. - `_decisions.md` Part 1 §"Backend language and framework" — the binding decision this ADR documents.