The SPA in frontend/src/ is split into layers. Each layer has one job
and may import only from the layers below it. tests/architecture.test.ts
checks the import rules on every test run, so a violation fails CI.
┌──────────────────────────────┐
│ views/ route shells │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ features/<section>/ │
│ use*.ts feature logic │
│ *.vue containers │
└───┬──────────────────────┬───┘
│ │
┌──────────────────▼─────┐ ┌────────────▼───────────────────┐
│ components/ │ │ stores/ shared state │
│ presentational only │ │ composables/ cross-feature │
└──────────────────┬─────┘ └────────────┬───────────────────┘
│ │
│ ┌────────────▼───────────────────┐
│ │ api/ HTTP + resources │
│ └────────────┬───────────────────┘
│ │
┌───▼──────────────────────▼───┐
│ models/ domain types, │
│ DTO ↔ model maps │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ domain/ pure TypeScript │
└──────────────────────────────┘
router/ route table, typed names, auth guard
main.ts composition root: wires Pinia, router, api, session
api/ and models/ sit side by side: models/ imports the DTO types
from api/dto.ts, and the resource modules in api/ never import
models. Stores and feature composables call the api and then map the
result with a model’s *FromDto function.
| Layer | Responsibility | May import | Must not import |
|---|---|---|---|
domain/ |
Money, local dates, RRULE text, budget status rules, enum labels. Pure functions and value types. | 3rd-party pure libraries (decimal.js) |
Vue, Pinia, the router, any other @/ layer |
api/ |
The one fetch call (http.ts), errors, generated schema types, DTO aliases, one module per resource. |
domain/ (not used today) |
Vue, Pinia, the router, models and everything above |
models/ |
The shapes the UI works with (camelCase, Money, LocalDate) and the DTO ↔ model mappers. |
domain/, @/api/dto |
the rest of api/, Vue, Pinia, everything above |
stores/ |
Session and state shared across routes: entity caches, the active account, sign-out reset. | api/, models/, domain/, Vue, Pinia |
the router, components, features, views |
composables/ |
Behaviour reused across features: async state, modals, search, infinite lists, autosave, form errors. | domain/, models/, @/api/errors, Vue |
the rest of api/, stores, the router, UI layers |
features/ |
One folder per section. Composables that load and mutate a section’s data; container components. | everything below, components/, the router |
views/ |
components/ |
Presentational SFCs: props in, events out. | domain/, models/ (types and pure helpers), composables/, other components |
api/, stores/, vue-router, features, views |
views/ |
One per route. Reads route params, calls a feature composable, lays out features and components. | everything except api/ |
api/ |
The architecture test also checks that the only fetch( call in
src/ is in api/http.ts.
domain/ is pure so money and date rules are unit-tested with
plain tables and reused anywhere, including the models.api/ knows nothing about the session. The session store hands
the transport callbacks (getToken, refresh, onAuthFailure), so
the transport is tested without Pinia and could be reused outside the
SPA.| Kind | Convention | Example |
|---|---|---|
| Domain module | lower camelCase noun | domain/budgetStatus.ts |
| Resource module | api/resources/<resource>.ts, <resource>Resource(http) |
budgetsResource → api.budgets |
| DTO type | <Thing>Dto, <Thing>CreateDto, <Thing>UpdateDto, <Thing>ListQuery |
BudgetDto, BudgetListQuery |
| Model type | the plain noun | Budget, Transaction |
| Mapper | <thing>FromDto, <thing>ToCreateDto, <thing>ToUpdateDto |
budgetFromDto |
| Store | stores/<noun>.ts, use<Noun>Store, id = file name |
useBudgetsStore ("budgets") |
| Composable | use<Behaviour>, one per file |
useInfiniteList |
| Feature composable | features/<section>/use<Thing>.ts |
features/budgets/useMoveMoney.ts |
| Container component | features/<section>/<Thing>Section.vue / <Thing>Sheet.vue |
features/budgets/MoveMoneySheet.vue |
| Presentational component | components/<section>/<Thing>.vue, or components/shared/ |
components/budgets/BudgetCard.vue |
| View | views/<Page>View.vue |
views/BudgetDetailView.vue |
| Route name | kebab-case, declared in router/types.ts |
budget-detail |
Every module starts with a header comment that says what it is for and
which layer it belongs to (Store layer., Feature composable
(budgets)., …).
A request from a feature to the server and back:
view ──► feature composable / store
│ api.budgets.get(id)
▼
api/resources/budgets.ts ──► http.get("/api/v1/budgets/<id>/")
│
▼
api/http.ts
1. Authorization: Bearer <getToken()>
2. fetch
3. 401? ──► refresh() (single-flight)
ok ──► retry once
fail──► onAuthFailure(AuthError)
throw AuthError
4. other non-2xx ──► throw ApiError
5. 2xx ──► parsed JSON (or null)
▲
│ BudgetDto
budgetFromDto(dto) ──► Budget ──► store cache / composable state
main.ts builds the client with
createSessionHttpClient({ onAuthFailure }) from stores/session.ts
and hands it to initApi(). The session store supplies the token
(accessToken) and the refresh (renewToken(), which posts to
/api/token/refresh/ with the httpOnly cookie).http.ts calls its refresh().
Concurrent 401s share one refresh request, because the backend
rotates the refresh cookie and blacklists the old one: a second
parallel refresh would carry a dead cookie and end a session the
first one just renewed. The original request is retried once with
the new token.session.endSession() (which resets every store) and then the
onAuthFailure callback from main.ts, which calls
redirectToLogin(router): the user lands on /app/login/?next=<the
page they were on>. The request then rejects with AuthError;
callers need not handle it beyond showing describeError(err).ApiError, which parses
DRF’s error body into detail, fieldErrors and nonFieldErrors.
useFormErrors().setError(err) puts them next to the right inputs;
describeError(err) gives a one-line message for a banner.See api-and-models.md for the transport’s options and the error shape.
router/index.ts holds the route table and the guard, and
router/types.ts holds the route types.
/app/. createAppRouter() uses
createWebHistory("/app/"). Django serves the SPA shell for every
path under /app/, so a reload or a pasted link lands on the right
view, and URLs have no #. Tests pass createMemoryHistory("/app/")
instead.AppRouteNamedMap in router/types.ts lists
every route’s name, path and params. It is registered as vue-router’s
RouteNamedMap, so router.push({ name, params }) and
<RouterLink :to> are type-checked, and a misspelt name or a missing
param fails vue-tsc. Navigate by name, never by a path string. A new
route goes into both files together.meta.access and the guard. Every route declares meta.access:
"public" (the login page, the email-change result pages, not-found)
or "authenticated" (everything else). The PUBLIC and
AUTHENTICATED constants in router/index.ts hold the two values.
authGuard runs before every navigation. It sends a signed-out visitor
on an authenticated route to login with ?next=<fullPath>, and it
sends a signed-in user on login to overview. redirectToLogin
(called when the session ends mid-use) does nothing on a public route.main.ts runs the silent
refresh, then loads the user and the account context, and only then
calls app.use(router). Installing the router runs the first
navigation through the guard, so the guard must see the settled
session, or a returning user would land on the login page.:id is a prop that views watch. Routes with an :id set
props: true, so the view takes id as a prop. Moving from one
record to another on the same route reuses the view instance, so a
view passes a getter (() => props.id) to its feature composable and
watches the prop for any other per-record state. See
adding-a-page.md./:pathMatch(.*)* (not-found),
catches every unknown path and renders NotFoundView. It is public,
so a signed-out visitor with a bad link sees the not-found page rather
than the login page./app/email-change/{confirmed,revoked,error}/, so those paths must
match the backend’s SPA_EMAIL_CHANGE_* constants.Every route’s component is a lazy import(), so each view is its own
chunk. adding-a-page.md has the steps for adding a
route.
Sections never import each other’s composables or reach into each other’s state. They share data in two ways:
budgets,
bankAccounts) update themselves from the server’s
answer to every mutation, so every reader sees the change.For example, moving money between budgets on the budget detail page changes the Unallocated balance the top bar shows. Neither knows about the other:
// features/budgets/useMoveMoney.ts -- the move-money sheet's logic
await store.transfer({ bankAccountId, srcBudgetId, dstBudgetId, amount });
// stores/budgets.ts -- create the transfer, then refetch both budgets into the cache
async function transfer(input: TransferInput): Promise<void> {
await api.internalTransactions.create(transferToCreateDto(input));
await Promise.all([fetchOne(input.srcBudgetId), fetchOne(input.dstBudgetId)]);
}
// features/shell/AppShell.vue -- the top bar's container
const unallocated = computed(() => budgets.byId(ctx.unallocatedBudgetId)?.balance ?? null);
When one side of the transfer is the Unallocated budget, AppShell’s
computed re-evaluates and the top bar updates. The budget detail page
reads its budget from the same cache, so its hero updates too.
The view wires the sheet to its own state with props and an event:
<!-- views/BudgetDetailView.vue -->
<MoveMoneySheet
:open="showMoveMoneyForm"
:budget="budget"
:fillup-budget="fillupBudget"
@close="showMoveMoneyForm = false"
/>