Working in the codebase
Archetype’s backend is a layered Django codebase with enforced app boundaries; the frontend is a Next.js app. This page explains the architecture rules you must follow when changing code, and the exact commands to run the tests, linters and boundary checks before you push.
Where things live
Section titled “Where things live”| Area | Path | Notes |
|---|---|---|
| Backend apps | api/apps/* | Django feature apps; routes under /api/v1/* |
| Search registry | api/apps/search/registry.py | Single source of truth for index types |
| Public site | frontend/app/(site)/* | App Router pages |
| Backoffice | frontend/app/backoffice/* | Staff authoring and management UI |
| Data access | frontend/lib/api-fetch.ts, frontend/services/backoffice/ | Public fetch layer; backoffice API client |
| Request logic | frontend/proxy.ts | CSP nonce, auth and feature gating (Next.js 16’s middleware.ts) |
| Production stack | infrastructure/compose.yaml | Full stack behind nginx |
Backend layout
Section titled “Backend layout”The Django backend lives in api/apps/ (the backend repository, cloned as api/), one directory per feature app:
apps/{common,users,manuscripts,symbols_structure,scribes,annotations,annotation_standards,publications,pages,worksets,search,uploads}(annotation_standards serves the IIIF Presentation and W3C Web Annotation output; uploads handles chunked image uploads.) Inter-app imports are not free-for-all — they follow a fixed dependency graph. common depends on nothing; search may read from manuscripts, scribes, symbols_structure, annotations and publications; manuscripts depends only on common and annotations; and so on. The full graph is declared in scripts/check_architecture_boundaries.py. Every app under apps/ (any directory with an apps.py) must have an entry there, or the check fails.
Run the boundary check before you push:
just check-architecture # uv run python scripts/check_architecture_boundaries.pyThe layering rules
Section titled “The layering rules”Keep each layer doing its one job:
- Views are transport-only. Validation, HTTP status mapping, and response shape — nothing else. No orchestration, no Celery dispatch.
- Application services own orchestration. Cross-model workflows and all Celery task dispatch live here (e.g.
apps/manuscripts/services/), not in views. - Domain services own mutation workflows. Write logic belongs in domain services.
- Serializers do not embed write logic. They shape data in and out; they must not drive mutations.
If you find yourself reaching for a model or task inside a view or a serializer’s create/update, push that logic down into a service instead.
The search registry pattern
Section titled “The search registry pattern”Search is registry-driven: apps/search/registry.py is the single declarative source of truth for every Meilisearch index. Each IndexType maps to one IndexRegistration holding the model, the document builder (from apps/search/documents/), the filterable/sortable/facet/searchable attribute lists, and the ORM prefetch spec — nothing about an index lives anywhere else.
Adding a search index is a three-part edit made together: the registry entry, the builder function in
apps/search/documents/, and the tests. Land them in the same change — a half-registered index is a broken one.
After changing indexes, recreate and resync them:
just setup-search-indexes # create the Meilisearch indexesjust sync-search-index item-parts # sync one index (positional arg)just sync-all-search-indexes # sync everythingRunning tests and linters
Section titled “Running tests and linters”The backend runs via Docker Compose through just recipes. conftest.py auto-selects SQLite when run outside Docker, and just pytest-focused forces SQLite inside the container, so the focused loop is fast:
just pytest # full pytest-django suitejust pytest-focused # fast SQLite subset (annotations + search services)just pytest-search # search tests onlyjust coverage # full run, --cov-fail-under=55just clean # ruff check --fixjust lint # mirrors the CI lint job (ruff check, ruff format --check, mypy)Frontend tests use Vitest with a jsdom environment. Each just recipe runs inside the frontend dev container; the pnpm equivalents work host-natively:
just test # pnpm test → vitest runjust lint # pnpm lint → oxlintjust format # pnpm format → prettier --checkjust bundle-budget # CI gate: builds, then fails if the gzip bundle exceeds .bundle-budget.jsonBoth repos ship pre-commit hooks in .githooks/ that run fast CI-equivalent checks (backend: ruff check, ruff format --check, uv lock --check, the architecture-boundary check; frontend: pnpm lint, pnpm format). They are not active until you enable them per clone with git config core.hooksPath .githooks — see the Contribution workflow. If a hook fails, fix it (uvx ruff format . or pnpm format:fix && pnpm lint:fix) and re-commit. The frontend bundle-budget gate can trip even when lint and tests pass — run just bundle-budget-update (or pnpm build && pnpm bundle-budget:update) and commit the refreshed budget after intentional growth.
For running an instance locally, see Configuration and Load your own corpus; for the HTTP surface, see The API.