DEV Community

Cover image for Clean Architecture in a FastAPI + Vue 3 monorepo
Oscar Rieken
Oscar Rieken

Posted on

Clean Architecture in a FastAPI + Vue 3 monorepo

Why architecture matters in a research project

Most research prototypes are throwaway code. NumPath is not. It needs to survive four phases over 30 weeks, accumulate real student data for a randomised controlled trial, and remain testable without live infrastructure at every step. That means the architecture has to enforce rules that hold up under pressure — not just conventions someone remembers to follow.

This post walks through how NumPath uses Clean Architecture to keep a FastAPI backend, a Vue 3 frontend, and a Python ML module in a single repository without coupling them together.

The monorepo layout

The project lives in a single repo with a clear namespace boundary:

phd-research/
├── numpath/
│   ├── backend/      # Python 3.12 + FastAPI + SQLAlchemy
│   ├── frontend/     # Vue 3 + Tailwind CSS + Pinia
│   └── ml/           # BKT, DKT, adaptive engine
├── docs/
│   ├── adrs/         # Architecture Decision Records
│   ├── architecture/ # System design, feature specs
│   └── posts/        # This blog series
└── DOMAIN_DICTIONARY.md
Enter fullscreen mode Exit fullscreen mode

The alternative was three separate repos. For a solo PhD project where a data model change touches the migration, the API schema, the ML engine, and the Vue component in the same commit, separate repos mean coordinated PRs across three remotes. That's overhead with no benefit when one person owns all three layers.

The escape hatch is clean: if NumPath ever needs to become a standalone repo, the numpath/ directory lifts out intact.

Dependency direction: the one rule that matters

Clean Architecture has many principles, but only one that I enforce mechanically: inner layers never import from outer layers.

In NumPath's backend, the layers are:

Domain (models)  →  Use Cases  →  Adapters (routers, DB, LLM)  →  Frameworks (FastAPI, SQLAlchemy)
Enter fullscreen mode Exit fullscreen mode

A use case like GetNextProblemUseCase receives a database session — but it does not import FastAPI, does not know about HTTP, and does not call Depends():

class GetNextProblemUseCase:
    def __init__(self, db: AsyncSession) -> None:
        self._db = db

    async def execute(self, student_id: uuid.UUID) -> NextProblemResponse:
        kc_states = await self._build_kc_states(student_id)
        recent_attempts = await self._fetch_recent_attempts(student_id)
        recent_mistakes = await self._fetch_recent_mistakes(student_id)

        selection: ProblemSelection = select_next_problem(
            kc_states=kc_states,
            recent_correctness=[row.is_correct for row in recent_attempts],
            current_difficulty=recent_attempts[0].difficulty if recent_attempts else 0.3,
            recent_mistakes=recent_mistakes,
        )

        problem = await self._select_problem(selection, ...)
        # ... return NextProblemResponse
Enter fullscreen mode Exit fullscreen mode

The router — the adapter layer — is the only file that knows about FastAPI:

@router.get("/next-problem/{student_id}", response_model=NextProblemResponse)
async def get_next_problem(
    student_id: uuid.UUID,
    db: AsyncSession = Depends(get_db),
    _: dict = Depends(require_student),
) -> NextProblemResponse:
    use_case = GetNextProblemUseCase(db)
    return await use_case.execute(student_id)
Enter fullscreen mode Exit fullscreen mode

The router does three things: parse the request, inject dependencies, and delegate to the use case. No business logic. If I replaced FastAPI with Litestar tomorrow, I'd rewrite the routers and touch nothing else.

Configuration as a boundary

Settings are another place where framework details leak into domain code if you're not careful. NumPath uses Pydantic's BaseSettings with a .env file:

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    DATABASE_URL: str = "postgresql+asyncpg://numpath:numpath@localhost:5432/numpath"
    LLM_PROVIDER: Literal["claude", "stub"] = "stub"
    ANTHROPIC_API_KEY: str = ""
    ENVIRONMENT: Literal["development", "production", "test"] = "development"
    CORS_ORIGINS: list[str] = ["http://localhost:5173"]
Enter fullscreen mode Exit fullscreen mode

Every secret has a default that works locally. LLM_PROVIDER defaults to "stub" so that tests and local dev never require an API key. The Literal type annotation means a typo in the .env file fails at startup, not at runtime when a teacher clicks "Generate insight."

The ML module as a pure function boundary

The ml/ directory is a separate Python package (numpath-ml) with its own pyproject.toml. The backend depends on it, but the dependency is narrow: two functions and a dataclass.

from numpath_ml.adaptive_engine import select_next_problem, ProblemSelection
from numpath_ml.bkt import KCState
Enter fullscreen mode Exit fullscreen mode

select_next_problem() takes dictionaries and lists — no SQLAlchemy models, no async, no database. It returns a ProblemSelection with a skill_code, target_difficulty, and reason string. The use case translates between database rows and these pure data structures.

This boundary exists because the ML code changes on a different cadence than the web application. When I replace the rule-based engine with Deep Knowledge Tracing in Phase 2, the use case stays the same — only the function it calls changes.

The frontend: same principle, different language

The Vue 3 frontend mirrors the same layering:

API client — a thin Axios wrapper that handles auth tokens and 401 redirects:

export const apiClient = axios.create({
  baseURL: '/api/v1',
})

apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})
Enter fullscreen mode Exit fullscreen mode

Stores — Pinia stores manage state. The auth store handles login/logout and persists the JWT to localStorage. Views consume stores, not the API client directly.

ViewsPracticeView.vue, TeacherView.vue, LoginView.vue. Each view composes API calls and store access. No view imports another view.

Router — role-based guards redirect students and teachers to their respective views:

router.beforeEach((to) => {
  const auth = useAuthStore()
  if (to.meta.requiresAuth && !auth.isAuthenticated) return '/login'
  if (to.meta.role && auth.role !== to.meta.role) {
    return auth.role === 'teacher' ? '/teacher' : '/practice'
  }
})
Enter fullscreen mode Exit fullscreen mode

Docker Compose as the integration layer

The four services — Postgres, Redis, backend, frontend — are composed with health checks so the backend waits for the database:

backend:
  environment:
    DATABASE_URL: postgresql+asyncpg://numpath:numpath@postgres:5432/numpath
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy
  volumes:
    - ./backend:/app/backend
    - ./ml:/app/ml
  command: uv run uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
Enter fullscreen mode Exit fullscreen mode

Volume-mounting backend/ and ml/ means hot reload works inside Docker — change a use case, save, and the server restarts. The port mapping (5433:5432 for Postgres) avoids collisions with a local Postgres install.

What this buys you

Three concrete benefits I've already seen:

  1. Test isolation — use cases are testable with a real async database session and no HTTP server. The test creates a GetNextProblemUseCase(db) directly. No FastAPI test client needed for business logic tests.

  2. LLM swappabilityGenerateInsightUseCase receives an LLMProvider protocol. In tests it gets StubProvider. In production it gets ClaudeProvider. The use case doesn't know which one it has.

  3. Safe ML replacement — when BKT gives way to DKT, only numpath_ml changes. The use case calls the same select_next_problem() function with the same signature. The router doesn't change. The frontend doesn't change.

What I'd do differently

I wouldn't change the layering, but I'd add one thing from day one: a fitness function that statically checks import direction. Right now the rule is "use cases don't import routers" — but it's enforced by code review (i.e., me reviewing my own code). A linter rule or CI check that fails on from backend.routers inside use_cases/ would catch drift automatically.

Key Takeaways

  • Dependency direction is the only architectural rule worth enforcing mechanically — inner layers never import outer layers; everything else is convention that erodes under deadline pressure
  • A monorepo is the right default for a solo research project — coordinated PRs across three repos is overhead without benefit when one person owns all layers and changes cut across them
  • Pure function boundaries between modules pay for themselves — the ML module exports two functions and a dataclass; the web layer translates between database rows and those pure structures, making the ML code replaceable without touching the application

Top comments (0)