API Refactor Plan — ArjanCodes Best Practices¶
Archived: fully implemented as of 2026-05-05. See PRs #370 (Phase 1), #375 (Phase 2), #377 (Phase 3), #379 (Phase 4), #380 (Phase 5), #381 (Phase 6).
Executive Summary¶
The portfolio FastAPI backend has accreted into a 2,404-line god module (api/main.py) holding
configuration, response schemas, ~1,000 lines of hardcoded seed data, every GET handler, inline
HTML email templates, subprocess CV orchestration, and the OpenAPI customizer. Routers admin.py
and uigen.py work around the resulting circular import by doing from api.main import settings
inside function bodies. Every read endpoint repeats the same if AsyncSessionLocal is None: return
STATIC_LIST branch and hand-rolled _orm_to_pydantic conversion. Seed content lives in two places
that can drift (Python lists in main.py and 1,103 lines of SQL inserts in alembic migration 0002).
This refactor restructures the backend into a layered FastAPI application —
config / schemas / repositories / services / routers — using ArjanCodes' patterns from his
examples repo (notably 2024/build_deploy_host_backend/skypulse, 2024/repository,
2026/ports, and 2026/clean). After the refactor:
api/main.pyshrinks to a thin app factory (~150 lines) that registers routers, middleware, and exception handlers.- Pydantic v2
from_attributes=Trueschemas replace all_x_to_yhelpers. - Per-domain routers and repositories eliminate cross-cutting duplication.
- One
seed.jsonis the single source of truth for static content; alembic and the fallback path both read from it. - Domain exceptions are translated to HTTP at the edge — handlers focus on the happy path.
- Email and CV generation become async services (no blocking subprocess on the event loop).
The plan is ordered so each phase is a safe, independently shippable PR. Phase 1 introduces zero behavior change — only file layout. Phases 2–4 progressively strip the god module. Phases 5–6 fix deeper architectural issues (seed-data drift, blocking subprocess, in-process token store, structured logging).
What We're NOT Doing (avoid over-engineering)¶
Per ArjanCodes' 2025/anti/ cautions and the project's actual scale (single repo, single team,
~9 entities):
- No
Repository[T]ABC with generics. Use plainasync deffunctions per aggregate. Generic base classes only pay off with 3+ implementations of the same interface — we have one (PostgreSQL via SQLAlchemy). - No DI container (no
dependency-injector,punq,kink, etc.). FastAPI'sDependscovers every injection need we have. - No CQRS, no event bus, no domain events. This is a content-serving API, not an aggregate-with-invariants domain.
- No hexagonal "ports & adapters" full split. ArjanCodes'
2026/portsexample is instructive but heavyweight; we adopt the idea (translate domain exceptions at the edge) without creating a separatedomain/package and adapter wiring layer. - No premature service interfaces.
EmailServiceandCvServiceare concrete classes/modules. We do not createIEmailServiceProtocols until there's a second implementation. - No DTO-per-layer ceremony. ORM models flow into Pydantic response schemas via
model_validate(orm_obj). We don't introduce a third "domain entity" layer. - No rewrite of
api/feature_flags.py,api/db/engine.py, orapi/db/models.py. Per the brief, these work — we extendmodels.py(Phase 5 timeline split) but don't restructure them. - No swap of slowapi, no swap of Alembic, no SQLModel migration. Stay on SQLAlchemy 2.0
Mapped[...]. - No abstraction over the DB-or-fallback pattern into a base class. A single fallback per repository function handles it cleanly without parameterized strategies.
- No HTML-template engine for OpenAPI customization — keep the existing custom hook, just relocate it.
Phase 1: Foundation — File Layout, No Behavior Change¶
Goal: break the circular import, give every concept a home, ship with zero functional diff. This phase alone unblocks everything downstream.
1.1 Extract Settings to api/core/config.py¶
Problem. Settings lives in api/main.py, so routers that need it (admin.py, uigen.py)
trigger a circular import and resort to from api.main import settings inside function bodies.
ArjanCodes pattern. 2024/build_deploy_host_backend/skypulse/app/core/config.py — Settings
class derived from pydantic-settings, single settings = Settings() module-level singleton
imported anywhere.
Files to create.
api/core/__init__.pyapi/core/config.py
Files to modify.
api/main.py— replace inlineSettingswithfrom api.core.config import settings.api/routers/admin.py— replacefrom api.main import settings(inside functions) with module-levelfrom api.core.config import settings.api/routers/uigen.py— same.
Steps.
- Copy the
Settingsclass verbatim frommain.pyintoapi/core/config.py. Keep the same field names, env var bindings, and defaults. - Export
settings = Settings()at module bottom. - In
main.py, delete the class and replacesettings = Settings()with the import. - In each router, hoist the import to the top of the file and remove the inside-function workaround.
- Run the API locally and
pytest api/— no diff in behavior.
Acceptance. grep -r "from api.main import settings" api/ returns nothing.
1.2 Carve out package skeleton¶
Problem. There's no place to put services, repositories, response schemas, exception types.
Files to create (empty __init__.py + placeholder modules).
api/repositories/__init__.pyapi/services/__init__.pyapi/schemas/responses.py(placeholder, populated in Phase 2)api/core/exceptions.py(placeholder, populated in Phase 3)api/core/logging.py(placeholder, populated in Phase 6)api/core/openapi.py(placeholder, populated in 1.4)api/data/directory (populated in Phase 5)
Steps. Create empty modules with docstring stubs only. No imports yet. This is purely scaffolding so subsequent phases land cleanly.
1.3 Move OpenAPI customizer to api/core/openapi.py¶
Problem. ~50–80 lines of custom OpenAPI hook bloat main.py.
ArjanCodes pattern. 2024/build_deploy_host_backend/skypulse/app/main.py keeps the FastAPI
factory minimal; cross-cutting setup lives in app/core/.
Files to create. api/core/openapi.py exposing
def custom_openapi(app: FastAPI) -> Callable[[], dict].
Files to modify. api/main.py — app.openapi = custom_openapi(app).
Acceptance. /openapi.json byte-identical to before.
1.4 Move inline HTML email templates to files¶
Problem. Two ~130-line HTML strings live in route handlers.
Files to create.
api/templates/__init__.pyapi/templates/contact_admin.htmlapi/templates/contact_user.html
Steps.
- Cut each HTML block into its own
.htmlfile. Replace string interpolation with Jinja2 placeholders ({{ name }},{{ message }}). - In
main.py(still — service extraction comes in Phase 4), load viajinja2.Environment(loader=PackageLoader("api", "templates")). Render into the existing send-email call. - Add
jinja2torequirements.txtif not present.
Acceptance. Sent test email visually identical to before.
Phase 2: Response Schemas with from_attributes=True¶
Goal: kill all _x_to_y hand-converters; let Pydantic v2 do the work.
2.1 Define response schemas in api/schemas/responses.py¶
Problem. Every route does _project_to_card(orm_project) etc. — 200+ lines of manual field
copying. ORM and response shapes drift silently.
ArjanCodes pattern. 2024/build_deploy_host_backend/skypulse/app/schemas/city.py — Pydantic v2
model with model_config = ConfigDict(from_attributes=True, populate_by_name=True) and
Field(alias="camelCase") for JSON field names while attributes stay snake_case.
Files to create. api/schemas/responses.py containing:
ProjectCard,ProjectDetail,ProjectPhaseWorkEntry,Skill,SkillCategoryAward,CertificationContactResponse,HealthResponse,FeatureFlagsResponse- A shared
BaseResponsewithmodel_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel).
Files to modify. api/main.py — delete each old response model class as it's superseded.
Replace _project_to_card(p) with ProjectCard.model_validate(p). Replace
_project_to_detail(p) with ProjectDetail.model_validate(p). Same for work, skills, awards,
certifications.
Steps.
- Create
BaseResponsewith shared config inapi/schemas/responses.py. - For each ORM model in
api/db/models.py, define its response schema mirroring exposed fields. UseField(alias=...)only where the JSON key differs from the attribute. - For nested data (project phases, skills inside categories), use list types — Pydantic walks
relationship()collections automatically whenfrom_attributes=True. - One domain at a time: replace handlers'
_x_to_y(obj)calls withSchema.model_validate(obj). Run tests after each. - Delete the now-unused converter helpers.
Acceptance. grep -n "_project_to_\|_work_entry_to_\|_skill_to_" api/main.py returns nothing.
Response payloads byte-identical (verify via snapshot test on /api/projects etc.).
2.2 Keep request schemas where they are¶
api/schemas/projects.py, experience.py, skills.py already hold the create/update DTOs.
Leave them. Optionally move them next to the response schemas for symmetry — but only if free;
not load-bearing.
Phase 3: Per-Domain Routers + Domain Exceptions¶
Goal: dissolve the god module's route section into one router per aggregate; centralize error → HTTP translation.
3.1 Domain exception hierarchy in api/core/exceptions.py¶
ArjanCodes pattern. 2026/ports/domain/errors.py (defines DomainError, NotFound,
Conflict as plain exceptions) + 2026/ports/api.py (FastAPI app.add_exception_handler(NotFound,
...) translates at the edge). Also 2024/build_deploy_host_backend/skypulse/app/exceptions/
exceptions.py for the factory pattern (create_exception_handler(status_code, message)).
Files to create. api/core/exceptions.py:
Plus a register_exception_handlers(app: FastAPI) -> None function using a
create_exception_handler(status_code) factory.
Files to modify. api/main.py — call register_exception_handlers(app) once during app
construction. Remove ad-hoc HTTPException(404, ...) from handlers as they're refactored
(repositories raise NotFoundError, handlers don't catch).
Acceptance. A repository raising NotFoundError("project", slug) results in a 404 response
with consistent JSON shape.
3.2 Per-domain GET routers¶
ArjanCodes pattern. 2024/build_deploy_host_backend/skypulse/app/api/routes/cities.py — one
router file per aggregate, each declaring its own APIRouter(prefix=..., tags=[...]).
2023/fastapi-router/api/routers/items.py for the basic shape.
Files to create.
api/routers/projects.py—GET /api/projects,GET /api/projects/{slug}api/routers/experience.py—GET /api/experienceapi/routers/skills.py—GET /api/skillsapi/routers/awards.py—GET /api/awardsapi/routers/certifications.py—GET /api/certificationsapi/routers/contact.py—POST /api/contactapi/routers/cv.py— CV download/status/generate routesapi/routers/health.py—GET /health,GET /api/healthapi/routers/feature_flags.py—GET /api/feature-flags,PATCH /api/feature-flags
Files to modify. api/main.py — replace inline route handlers with
app.include_router(projects.router) etc. Order routers under a def create_app() -> FastAPI
factory.
Steps (one router at a time).
- Move the handler function and decorator into the new router file.
- Update imports:
settingsfromapi.core.config, response schemas fromapi.schemas.responses, DB dep fromapi.db.engine. - Keep handlers thin: param parse → call repository (Phase 4) or, transitionally, the existing
inline DB query → return
Schema.model_validate(...). - Register the router in
main.py. - Delete the original inline handler and any helpers used only by it.
- Run tests; ship; move to next router.
Acceptance. wc -l api/main.py drops below 600 by end of phase. Each router file is under
~150 lines.
3.3 Convert main.py into an app factory¶
Files to modify. api/main.py should end up as roughly:
Acceptance. Module-level side effects limited to app = create_app(). Easy to instantiate a
second app for testing.
Phase 4: Repository + Service Layers¶
Goal: handlers stop touching AsyncSession directly. Cross-cutting "DB-or-fallback" logic lives
in one place.
4.1 Two DB dependencies¶
Problem. get_db() returns Optional[AsyncSession] and every handler null-checks.
ArjanCodes pattern. 2026/ports/api.py — explicit dependency providers; the handler declares
what it needs and the DI layer enforces it.
Files to modify. api/db/engine.py — add two explicit dependencies:
async def get_db() -> AsyncIterator[AsyncSession]— raisesDatabaseUnavailableErrorif engine not configured. Used by write endpoints (admin, contact-persistence) and reads with no static fallback.async def get_optional_db() -> AsyncIterator[AsyncSession | None]— current behavior; used by read endpoints that have a static fallback.
Acceptance. Admin routes no longer need to null-check; they get a 503 automatically via the
exception handler if the DB is down.
4.2 Repository functions per aggregate¶
ArjanCodes pattern. 2024/build_deploy_host_backend/skypulse/app/crud/cities.py — module of
free async def functions that take db: AsyncSession and typed args, return ORM rows or raise
domain errors. NOT 2024/repository/repository.py's ABC (see anti-pattern note above).
Files to create.
api/repositories/projects.py—list_cards(db),get_by_slug(db, slug),slugs_ordered(db),create(db, payload),update(db, slug, payload),delete(db, slug),reorder(db, ordered_slugs).api/repositories/experience.py—list_entries(db),get_by_id(db, id), plus admin CRUD.api/repositories/skills.py—list_categories(db), plus admin CRUD for skills and categories.api/repositories/awards.py,api/repositories/certifications.py— list + admin CRUD.
Conventions.
- Read functions return ORM models (Pydantic schemas validate at the router edge).
- "Get one" functions raise
NotFoundError(entity, key)instead of returningNone— handler doesn't need to translate. - Write functions raise
ConflictErroron unique-constraint violations (catchIntegrityError, re-raise as domain error). - No transaction management inside repos;
get_db()wraps each request inasync with db.begin().
Files to modify. Each router file calls the repository instead of inlined select(...)
statements. Handler shape becomes:
Acceptance. grep -n "select(" api/routers/ returns nothing. All ORM queries live under
api/repositories/.
4.3 Centralize the DB-or-fallback pattern¶
Problem. Read endpoints branch: if AsyncSessionLocal is None: return STATIC_LIST.
Decision. Push fallback into the repository, not into a service wrapper. Each read repo
function accepts db: AsyncSession | None and falls back to seed.load_*() (Phase 5) when
None. Single place per domain.
Note. Pydantic schemas with from_attributes=True validate both ORM rows and plain dicts —
no special case at the router edge.
Acceptance. No AsyncSessionLocal is None checks remain in router code.
4.4 Email service in api/services/email.py¶
Problem. Email send + Jinja2 rendering currently lives in the contact route handler.
ArjanCodes pattern. 2025/di/manual.py (DataPipeline composes injected pieces) and
2026/clean/report_after.py (free functions composing a pipeline).
Files to create. api/services/email.py:
class EmailServicewith constructor(settings: Settings, jinja_env: Environment)and async methodssend_admin_notification(...),send_user_confirmation(...).- Module-level
def get_email_service() -> EmailServicefor use as a FastAPI dependency.
Files to modify. api/routers/contact.py — handler injects EmailService via
Depends(get_email_service) and calls it.
Acceptance. Contact handler under 30 lines.
4.5 CV generation service in api/services/cv.py — async subprocess¶
Problem. _run_cv_generation calls blocking subprocess.run(..., timeout=120) inside an
async handler. Any concurrent CV request stalls the event loop.
Files to create. api/services/cv.py:
async def generate_cv(...) -> Pathusingasyncio.create_subprocess_exec(...)withawait proc.communicate()andasyncio.wait_for(..., timeout=120).- Helpers for input validation and output path resolution.
Files to modify. api/routers/cv.py — handler awaits the service.
Acceptance. Two simultaneous POST /api/generate-cv requests don't block each other
(verified with httpx.AsyncClient parallel calls in tests).
Phase 5: Single Source of Truth for Seed Content + Schema Cleanup¶
Goal: kill the dual-source drift between main.py static lists and the 1,103-line alembic seed
migration. Replace the timeline_encoded array hack with a proper child table.
5.1 Single seed file¶
Files to create.
api/data/__init__.pyapi/data/seed.json— canonical content for projects, work history, skills, awards, certifications. Structure mirrors the response schemas (snake_case keys to match ORM attributes; Pydantic handles aliasing).api/data/seed.py— loader:def load_projects() -> list[dict],load_work_entries(), etc. Caches parsed JSON in module-level dicts after first read.
Files to modify.
- Delete the hardcoded
PROJECT_CARDS,PROJECT_DETAILS,WORK_HISTORY,SKILL_CATEGORIES,CERTIFICATIONS,AWARDSconstants frommain.py(largely gone already after Phase 4.3 if fallbacks point atseed.load_*). - Repository fallback paths import from
api.data.seed.
Steps.
- Generate
seed.jsononce: write a one-shot local script that dumps the existing Python lists from the oldmain.pyto JSON. Commit only the JSON. - Implement
seed.pyloader (lazy-cached). - Wire fallback paths (Phase 4.3) to the loader.
- Verify:
GET /api/projectswith DB stopped returns the same payload as before.
5.2 Refactor alembic seed migration to read from seed.json¶
Files to modify. alembic/versions/0002_seed_initial_data.py — replace 1,103 lines of inline
SQL with code that:
- Reads
api/data/seed.json(viapathlib.Path(__file__).parents[2] / "data" / "seed.json"). - Issues bulk inserts via
op.bulk_insert(...)per table.
Important — migration immutability policy. If the chosen policy is "migrations are
immutable," do NOT modify 0002. Instead leave it as-is and create a new migration
0005_reseed_from_json.py that deletes and re-inserts from the JSON whenever content changes.
Document the chosen policy in the ADR before proceeding.
Idempotency. Whichever approach is taken, use ON CONFLICT DO NOTHING on natural keys so
rerunning against an already-seeded DB is a no-op.
Acceptance. Editing seed content means editing one JSON file; both DB seed and the no-DB fallback reflect the change immediately.
5.3 Replace timeline_encoded with a child table¶
Problem. Project phases are stored as ARRAY(Text) of "name|||description" strings.
Files to modify.
api/db/models.py— addclass ProjectPhasewith(id, project_id FK, ordinal, name, description)andrelationshipfromProject.phases. Droptimeline_encodedfromProject.- New migration
alembic/versions/0005_project_phases_table.py— create table, copy data fromtimeline_encoded(parse on|||), drop column. api/repositories/projects.py— load phases via relationship (selectinload(Project.phases)).api/schemas/responses.py—ProjectDetail.phases: list[ProjectPhase]already in place from Phase 2; field stays the same shape.
Acceptance. Round-trip migration on a populated DB preserves phase order and content.
timeline_encoded column gone.
Phase 6: Cross-Cutting Concerns — Logging, Request IDs, External Token Store¶
Goal: production-readiness fixes that don't fit a domain.
6.1 Structured logging + request ID middleware¶
ArjanCodes pattern. 2024/build_deploy_host_backend/skypulse/app/core/logging.py — loguru
with InterceptHandler to capture stdlib + uvicorn logs into one structured stream.
Files to create.
api/core/logging.py—configure_logging()sets up loguru +InterceptHandler; JSON sink in production, pretty sink in dev (gated onsettings.environment).api/core/middleware.py—RequestIdMiddlewarereads/generatesX-Request-Id, binds it to acontextvars.ContextVar, attaches to response header. Loguru filter pulls it into every log line.
Files to modify. api/main.py create_app() calls configure_logging() and
app.add_middleware(RequestIdMiddleware).
Add to requirements.txt. loguru.
Acceptance. Every log line includes request_id field. X-Request-Id round-trips on
responses.
6.2 Move UIGen guest tokens to Redis (or DB-backed table)¶
Problem. _guest_tokens: dict is in-process — multi-replica Container Apps lose tokens
across instances.
Decision. Choose the lighter option given existing infra:
- Option A (preferred if Redis already provisioned): add
redis.asyncioclient; tokens get a TTL-bound key. - Option B (no new infra): add a
uigen_tokenstable(id, token_hash, issued_at, expires_at, revoked_at)and clean up via a periodic job or lazy expiry on read.
Files to create. api/services/uigen_tokens.py with issue(), validate(), revoke().
Backend (Redis vs. DB) is a single import boundary.
Files to modify. api/routers/uigen.py calls the service. The in-process dict is deleted.
Acceptance. Guest token issued by replica A validates against replica B.
6.3 Verify scale-safety of slowapi¶
slowapi's default in-memory storage has the same multi-replica issue. If multi-replica is in scope, point it at the Redis from 6.2 (or defer — slowapi is correctness-non-critical). Document the decision; do not silently leave it broken.
Phase Summary & Sequencing Rationale¶
| Phase | Risk | Behavior change | Unlocks |
|---|---|---|---|
| 1. Foundation | Very low | None | Removes circular import; gives every concept a home |
| 2. Response schemas | Low | None (snapshot-tested) | Kills hand-rolled converters |
| 3. Routers + exceptions | Low | None | Shrinks main.py; centralizes error handling |
| 4. Repos + services | Medium | Equivalent | Removes duplicated DB-or-fallback; async CV gen |
| 5. Seed + schema | Medium | DB schema migration | Eliminates dual-source drift; clean phases table |
| 6. Logging + token store | Low–Medium | Token storage moves out-of-process | Multi-replica safety |
Each phase should be one PR (or a small handful), independently revertable. Do not start Phase N+1 until Phase N is merged and green in CI.
Critical Files for Implementation¶
| File | Role |
|---|---|
api/main.py |
Shrinks from 2,404 lines to ~150-line app factory |
api/core/config.py (new) |
Single Settings home; breaks circular import |
api/core/exceptions.py (new) |
Domain exception hierarchy |
api/schemas/responses.py (new) |
Replaces all _x_to_y hand-converters |
api/repositories/projects.py (new) |
Template for all aggregate repos |
api/data/seed.json (new) |
Single source of truth for static content |
api/db/models.py |
Extended in Phase 5 with ProjectPhase child table |
api/services/email.py (new) |
Extracted email + Jinja2 service |
api/services/cv.py (new) |
Async subprocess CV generation |
ArjanCodes Reference Map¶
| This refactor | ArjanCodes example |
|---|---|
api/core/config.py |
2024/build_deploy_host_backend/skypulse/app/core/config.py |
Response schemas with from_attributes |
2024/build_deploy_host_backend/skypulse/app/schemas/city.py |
| Per-domain routers | 2024/build_deploy_host_backend/skypulse/app/api/routes/cities.py |
| Repository functions (function-style) | 2024/build_deploy_host_backend/skypulse/app/crud/cities.py |
| Domain exceptions + HTTP translation | 2026/ports/domain/errors.py + 2026/ports/api.py |
| Exception handler factory | 2024/build_deploy_host_backend/skypulse/app/exceptions/exceptions.py |
EmailService / CvService |
2025/di/manual.py, 2026/clean/report_after.py |
create_app() factory pattern |
2024/build_deploy_host_backend/skypulse/app/main.py |
| Frozen dataclass config objects | 2026/clean/report_after.py (TrainingConfig) |
| Two DB deps (required vs. optional) | 2026/ports/api.py (explicit dependency declaration) |
| Structured logging + InterceptHandler | 2024/build_deploy_host_backend/skypulse/app/core/logging.py |
| Anti-patterns to avoid | 2025/anti/ (abstraction, static_methods, global_coupling, overengineering) |