Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.
Date: 2026-05-20
Status: Accepted
Feature: FEAT-016
Deciders: Daniel Meza
Context and Problem Statement
apps/api is a Node.js / Express / TypeScript service. apps/ingestion is a Python / FastAPI
service. Running both runtimes in the monorepo requires maintaining two package managers
(pnpm for Node, uv/pip for Python), two test toolchains (Vitest/supertest for the API,
pytest for ingestion), and two Railway build environments — without any functional benefit
that demands both.
The Express API is in an early TDD stub state: every route group exists but the repository
layer is unimplemented (StubProductRepository throws new Error('not implemented')).
No production traffic has reached it. This makes the refactor low-risk: there is no live
behaviour to preserve beyond the HTTP contract already captured in docs/specs/openapi.yaml.
Decision Drivers
- Unified runtime. A single Python backend means one package manager, one test framework, one set of Railway build configs. The ingestion service already establishes the pattern.
- FastAPI + SQLAlchemy 2 async is production-grade. FastAPI's automatic OpenAPI generation
provides a built-in contract-drift guard: the test suite can diff the generated spec against
docs/specs/openapi.yamland fail if they diverge. - Python 3.12 parity with ingestion. Both services share
.python-version, tooling config (ruff, pytest), and the same testcontainers-python approach for DB integration tests. - Preserved HTTP contract. All
/api/v1/paths, request/response shapes, status codes, and error envelope format remain byte-for-byte identical.docs/specs/openapi.yamlis the spec; the FastAPI-generated/openapi.jsonmust match it. - Preserved DB schema. The SQL migrations in
packages/db/migrations/are untouched. Alembic is introduced to apply those existing SQL files (no Python migration rewrite). - Low-risk timing. The TS API is pre-production and stub-only. There are no real users, no live data, and no integrations that require the Node.js runtime.
Considered Options
- Keep Express/TypeScript. Continue implementing the TS API. Benefit: no migration cost. Cost: permanent dual-runtime overhead; test toolchain split between Vitest and pytest.
- Replace with Python FastAPI (chosen). One-time refactor cost; unified runtime afterward.
- Rewrite in a different framework (Django, Flask, etc.). FastAPI is chosen over Django because it is async-first, produces OpenAPI natively, and matches the ingestion service pattern. Flask is synchronous by default, making async DB operations more complex.
Decision Outcome
Option 2: Replace apps/api with Python FastAPI.
What changes
| Aspect | Before (TS) | After (Python) |
|---|---|---|
| Language | TypeScript | Python 3.12 |
| Framework | Express 4 | FastAPI |
| Test framework | Vitest + supertest | pytest + httpx |
| ORM / DB layer | node-postgres (raw SQL) | SQLAlchemy 2.x async + asyncpg |
| Migration runner | node-pg-migrate | Alembic (wrapping existing SQL files) |
| Package manager | pnpm (workspace member) | pip / pyproject.toml |
| Railway builder | Nixpacks (Node) | Nixpacks (Python auto-detect) |
What is preserved
| Aspect | Status |
|---|---|
HTTP contract — all /api/v1/ paths |
Unchanged |
| Request/response shapes | Unchanged |
| Status codes and error envelope | Unchanged |
docs/specs/openapi.yaml |
Reconciled but not structurally changed |
packages/db/migrations/0001_init.sql |
Unchanged |
| DB schema (all tables, types, indexes) | Unchanged |
apps/web and apps/ingestion |
Untouched |
packages/db, packages/types |
Untouched (TS packages remain for web + legacy) |
| Railway environment structure | Unchanged (single production, no new services) |
Migration strategy for Alembic
Alembic is configured in apps/api/alembic/. A single Python migration file
(alembic/versions/0001_init.py) uses op.execute() to run the full text of
packages/db/migrations/0001_init.sql verbatim. This satisfies the requirement to keep
the SQL source unchanged while giving Alembic its version-tracking table (alembic_version).
The previous node-pg-migrate migration command (tracked in pgmigrations) is replaced by:
railway run --service api --environment production \
alembic upgrade headNote: On a production database that has already had 0001_init applied via
node-pg-migrate, run alembic stamp head to mark the migration as applied without
re-executing the DDL. New databases use alembic upgrade head normally.
Contract-drift guard
apps/api/tests/test_contract_drift.py loads the FastAPI app, fetches /openapi.json,
and diffs it against docs/specs/openapi.yaml. Any structural divergence fails the test.
This replaces the Zod schema validation in the TS contract tests as the primary contract guard.
Re-migration cost
If the team later chooses to return to TypeScript for the API service, the porting cost is equivalent to this refactor (≈ a sprint of work). The HTTP contract, DB schema, and OpenAPI spec are language-agnostic and require no changes in either direction.
Consequences
Positive
- One runtime, one test framework, one package manager for all backend services.
- FastAPI's auto-generated
/openapi.jsonprovides a living contract guard. pnpm-workspace.yamlno longer includesapps/api;pnpm installis faster.- Railway build config for
apiis simpler (nocorepack enable && pnpm install --frozen-lockfile). - SQLAlchemy 2.x async with asyncpg is a mature, well-typed stack with excellent Railway support.
Negative / Trade-offs
- One-time refactor cost. All TS route implementations and tests are discarded and rewritten in Python. Mitigated by the low volume (the TS code was stub-only, not production).
- pnpm workspace still exists for
apps/web. The monorepo is not fully unified —apps/web(Next.js) still uses pnpm. A future ADR could address this if it becomes friction, but there is no compelling reason to change the web app's stack. - Alembic + node-pg-migrate coexist during transition. After merging, the
pgmigrationstable (node-pg-migrate) andalembic_versiontable (Alembic) both exist on the production DB. Thepgmigrationstable is inert (no code reads it anymore) but is not dropped — it serves as a historical record. If cleanup is desired, a later migration canDROP TABLE pgmigrations.
References
- FEAT-016 plan
- docs/specs/openapi.yaml
- packages/db/migrations/0001_init.sql
- docs/testing.md
apps/ingestion/pyproject.toml— the Python template- FastAPI docs: https://fastapi.tiangolo.com
- SQLAlchemy 2 async: https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html