Skip to content

KOYASH Architecture

This is the maintained architecture index for KOYASH. It documents the current delivered system — the structures, components, and decisions needed to reason about how the product works, how it is deployed, and how it can evolve.

KOYASH is a skincare recommendation service: a React + Vite single-page frontend collects the user's budget, ethical preferences, allergens, skin concerns, and skin type, and a FastAPI backend turns that into a structured "cosmetic bag" of real products with a per-product justification. Product data lives in MongoDB Atlas. In MVP v2 an LLM justification layer (gpt-4o-mini, justification-only) sits on top of the existing rule-based engine — implemented and off by default, enabled by configuration. In MVP v3 a guest-first account layer (ADR-004) is added on top: the questionnaire and /recommend stay usable anonymously, while a signed-in user gets a saved profile, one saved cosmetic bag with feedback and replacement, and a result tracker — all persisted in MongoDB Atlas.

This document covers the maintained architecture views and decisions:

  • Static view — component diagram + commentary
  • Dynamic viewPOST /recommend sequence diagram
  • Deployment view — Railway + MongoDB Atlas
  • Architecture Decision Records (ADRs) — four records, each linked to a quality requirement

Maintained architecture assets live under:

Quality requirements referenced below are defined in docs/quality-requirements.md and verified by the tests in docs/quality-requirement-tests.md.


Static View — Component Diagram

KOYASH component diagram

Source: static-view/component-diagram.puml (PlantUML). The SVG is the rendered form of that source; re-render after editing the .puml.

What the diagram shows

The diagram captures the main internal components, the external systems they talk to, and the protocols between them.

  • Frontend (React + Vite SPA, served on Railway). The landing page and the three questionnaire flows (storytelling, short, and the new skin-type mini-quiz) collect the user's answers. A request builder (quizConfig.buildRequest) assembles them into the /recommend payload, and the results view renders the returned cosmetic bag. The frontend talks to the backend only over HTTPS, calling POST /recommend (and GET /products).
  • Backend (FastAPI on Uvicorn, on Railway). main.py wires CORS and exposes /health. Two routers handle the API surface: POST /recommend and GET /products. The recommendation engine (app/api/recommend.py) applies the hard filters (vegan and cruelty-free as a MongoDB query; allergens case-insensitively in Python), then does segment-priority selection, skin-type preference, and basket assembly. The rule-based justification builder produces the per-product explanation today; in MVP v2 the LLM justification layer (ADR-001) enriches that text using gpt-4o-mini, with a fallback to the rule-based text. Data access is a thin Motor (async MongoDB) client, and config is centralized in pydantic settings read from the environment. In MVP v3 an Accounts API (/auth, /account, /tracker) and a Security component (bcrypt + JWT, ADR-004) sit alongside the engine: /recommend takes an optional auth dependency, so it persists the profile and saved bag for a signed-in user while staying fully guest-usable, and the Accounts API reuses the engine's single-step selection to offer product replacements.
  • External systems. MongoDB Atlas stores the products collection (plus the MVP v3 users, care, and tracker collections), reached over the MongoDB wire protocol (TLS) via Motor. The LLM provider (OpenAI-compatible endpoint, gpt-4o-mini) is reached over HTTPS REST. The data seed scripts in db/ populate Atlas offline from the source dataset and are not part of the request path.

Coupling and cohesion

  • Frontend ↔ backend are loosely coupled through a single, explicit HTTP JSON contract (RecommendRequest / RecommendResponse pydantic models). They share no code and deploy independently — the frontend only depends on the shape of the API response.
  • The recommendation engine is highly cohesive but concentrated. One module (recommend.py) owns hard filtering, segment fallback, skin preference, basket assembly, and (today) justification. That makes the core logic easy to locate and test, but the module is large; the selection logic and the justification logic are already separate functions, which is the natural seam to split along as the LLM layer and budget work grow.
  • External dependencies are isolated behind accessors. The engine reaches the database only through get_database() / Motor, and the allergen filter deliberately runs in application code rather than in the database query. The LLM layer is introduced as a separate component the justification step calls, not as a dependency baked into selection — so the deterministic engine stays decoupled from the LLM.
  • Configuration is centralized in one pydantic Settings object sourced from environment variables, so runtime/secret configuration is not scattered across modules.

Maintainability implications

  • The clear HTTP contract gives a stable, testable boundary between the two deployables (supports Testability) and lets the frontend and backend evolve on independent release cadences.
  • Keeping selection deterministic and the LLM strictly additive (ADR-001) means MVP v2 can add richer explanations without putting an external, variable-latency service on the critical selection path — protecting modifiability and performance headroom.
  • The main maintainability risk is the size of recommend.py: as the LLM layer (PBI-303) and budget-precision work (PBI-302) land, splitting selection and justification into separate modules will keep each change local and reviewable.

Quality requirements this structure supports or constrains

  • QR-001 — Allergen-safe recommendations (Functional correctness). The allergen exclusion lives in application code inside the engine, in one place, so it can be verified directly and continuously (QRT-001) rather than trusted to a database query.
  • QR-002 — Robust recommendation across the input space (Fault tolerance). Segment-priority fallback and the empty-basket → structured 422 NO_PRODUCTS_AVAILABLE path are both owned by the engine, so the system degrades gracefully instead of failing on sparse filter combinations.
  • QR-003 — Recommendation response time (Time behaviour). The core path is a single MongoDB query plus in-process selection with no per-product external calls. The LLM layer is the main latency risk, which is exactly why ADR-001 keeps it off the selection path and optional.
  • QR-004 — Credential confidentiality (Security). The account layer keeps passwords as bcrypt hashes in the Security component and excludes them from the public UserOut model, so credentials never leave the backend (ADR-004).

Dynamic View — POST /recommend Sequence

POST /recommend sequence diagram

Source: dynamic-view/recommend-sequence.puml (PlantUML). The SVG is the rendered form of that source.

What scenario this represents

This is the core request: a user finishes the questionnaire and the system assembles their cosmetic bag. The frontend builds the /recommend payload and calls the backend over HTTPS; the recommendation engine queries MongoDB Atlas, applies the filters, selects one product per routine step, builds the justification, and returns the bag — or returns a structured 422 when no products survive the filters.

Why it matters to the product

POST /recommend is the moment of value in KOYASH: everything else exists to get the user to this response. It is also the most complex flow — it spans the frontend, the API, the in-process engine, the database, and (in MVP v2) the external LLM provider, and it has to behave correctly both when products are found and when the filter combination leaves nothing. That makes it the right flow to reason about correctness, robustness, and latency.

What the sequence shows

  1. The frontend assembles the request (buildRequest) and posts it.
  2. The engine builds the MongoDB query for the two database-side hard filters (vegan, cruelty-free) and fetches matching documents.
  3. The allergen filter runs in Python over the result set (case-insensitive) — deliberately in application code, not in the query.
  4. For each routine step the engine picks segment-priority candidates (the user's own budget segment first, then the nearest neighbour), applies the skin-type preference (specific type, else any), and sorts by concern match then price.
  5. If the basket ends up empty, the engine raises 422 NO_PRODUCTS_AVAILABLE and the frontend shows the "nothing found" state.
  6. Otherwise each item gets a justification. In MVP v2 the LLM layer (ADR-001) optionally enriches the wording via the external provider, and falls back to the rule-based text if the LLM is unavailable — the LLM never changes which products were selected.
  7. The engine sorts the bag by routine order, computes the total and notes, and returns RecommendResponse, which the frontend renders.

Decisions and quality requirements it helps reason about

  • Integration boundaries. The diagram makes the two external dependencies explicit — MongoDB Atlas on the critical path, and the LLM provider as an optional side-branch off the justification step. This is exactly the boundary ADR-001 draws: selection is deterministic and local; the LLM is additive and bypassable.
  • QR-001 (allergen correctness). The allergen filter is a distinct, in-process step (step 3), which is what makes it directly testable.
  • QR-002 (fault tolerance). The explicit empty-basket alt branch shows the graceful 422 path instead of a crash or malformed body.
  • QR-003 (time behaviour). The core path is one database round-trip plus in-process work; the only variable-latency call (the LLM) sits in an opt block off the critical selection path.

Deployment View

KOYASH deployment diagram

Source: deployment-view/deployment-diagram.puml (PlantUML). The SVG is the rendered form of that source.

What the deployment shows

  • Two services on the Railway platform. The frontend service is the React + Vite app built to a static bundle and served via vite preview (bound to 0.0.0.0 on Railway's PORT). The backend service is the FastAPI app packaged as a Docker image (python:3.12-slim, Uvicorn, listening on Railway's PORT).
  • Customer-facing access path. The user's browser loads the SPA from the frontend service over HTTPS, then calls the backend directly over HTTPS (POST /recommend, GET /products) — CORS on the backend allows the frontend origin. The backend URL is wired into the frontend at build time via VITE_API_URL.
  • Stateful infrastructure. Product data lives in MongoDB Atlas (managed cloud), reached from the backend over the MongoDB wire protocol (TLS, mongodb+srv) via the async Motor driver. In MVP v3 the account data (users, care, tracker) lives in the same Atlas database.
  • External service (MVP v2). The backend calls the external LLM provider (OpenAI-compatible, gpt-4o-mini) over HTTPS REST, using an API key supplied through the environment.
  • Offline data pipeline. The db/ seed scripts run from a developer machine as a one-off to import the catalog into Atlas; they are not part of the deployed runtime or the request path.

Why this deployment model

  • Railway for both services keeps the operational surface small for a student team: managed builds and TLS, environment-variable configuration, and a public URL per service, with no separate orchestration to run. The frontend and backend deploy independently, matching their loose HTTP coupling.
  • MongoDB Atlas removes the need to operate a database: backups, TLS, and availability are managed, and the connection string is just another secret in the backend environment.
  • The LLM provider stays external and isolated to one outbound call from the backend, consistent with ADR-001 — it can be enabled, disabled, or swapped via configuration without touching the deployment topology.

How it supports or constrains the product

  • The browser-to-backend call is a direct cross-origin HTTPS request, so the backend must keep correct CORS and the frontend must be built with the right VITE_API_URL for each environment — a deploy-time configuration concern to watch.
  • Atlas and the LLM provider are both off-platform network hops; their latency and availability are outside Railway. This is why QR-003 is measured in-process (excluding network/Atlas round-trips) and why the LLM call is optional and bypassable (QR-002).

What to consider when operating it

  • Set the backend secrets (MONGODB_URI, JWT_SECRET, the LLM API key) and the frontend VITE_API_URL per environment; never commit them. JWT_SECRET must be a strong random value in production (a dev-only default exists in config).
  • Keep the Atlas network access list and the LLM key budget in mind — the key is usage-limited.
  • Both services read PORT from Railway; the frontend must keep allowedHosts open for the *.up.railway.app domain.

Architecture Decision Records (ADRs)

The maintained ADR set lives in adr/. Each record is Accepted and addresses one or more quality requirements.

ADR Decision Quality requirements
ADR-001 Rule-based selection engine with the LLM as a justification-only layer QR-001, QR-003
ADR-002 MongoDB Atlas as the product datastore QR-002
ADR-003 Budget as discrete segments with nearest-segment fallback QR-002
ADR-004 Guest-first authentication with bcrypt + JWT QR-004

How the decisions fit the architecture

These four decisions explain the shape of the system shown in the views above:

  • ADR-001 is why the static view draws the LLM as a separate, optional component hanging off the justification step rather than inside the engine, and why the dynamic view puts the LLM call in an opt block off the critical path. Keeping selection deterministic is what makes QR-001 (allergen correctness) testable and QR-003 (latency) achievable.
  • ADR-002 is why the static and deployment views show a single managed datastore (MongoDB Atlas) reached through the Motor data-access component, and why the allergen filter sits in application code rather than in the query.
  • ADR-003 is why the engine does per-step, segment-priority selection with substitution notes instead of price-band enforcement — the mechanism behind the QR-002 graceful-degradation behaviour. Its tradeoff (the assembled total is not a fixed ₽ range) was resolved by PBI-302 at the presentation layer: the budget step now shows an approximate tier price and total, and the results note that prices are approximate — the engine is kept as-is.
  • ADR-004 is why the static view draws the account layer as an additive Accounts API + Security component rather than gating the engine: the /recommend path takes an optional auth dependency, so the guest funnel is untouched while signed-in users persist a profile, saved bag, and tracker in the same Atlas datastore (ADR-002). Keeping passwords as bcrypt hashes and out of the response models is what makes QR-004 (credential confidentiality) hold.