HR-ATS-Portal/docs/architecture/adr/0018-backend-language-and-f...

18 KiB
Raw Blame History

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 200600 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.

  • 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.