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
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)
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
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)
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"]
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
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
})
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.
Views — PracticeView.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'
}
})
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
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:
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.LLM swappability —
GenerateInsightUseCasereceives anLLMProviderprotocol. In tests it getsStubProvider. In production it getsClaudeProvider. The use case doesn't know which one it has.Safe ML replacement — when BKT gives way to DKT, only
numpath_mlchanges. The use case calls the sameselect_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)