Architecture
This page describes how the pieces of Coreola fit together — how a request flows from a click in the browser to data on the screen, and how the codebase is organized to make that flow predictable.
If you have not yet seen the folder layout, start with Project Structure.
High-level shape
Coreola is a single-page React application served by Vite, talking to one or two HTTP backends, and persisting a small slice of state to local storage.
┌────────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ React app (Vite-bundled) │ │
│ │ │ │
│ │ Pages (View) ──▶ Model hooks ──▶ RTK Query │ │
│ │ ▲ │ │ │ │
│ │ │ ▼ ▼ │ │
│ │ MUI components Redux store baseQuery (HTTP)│ │
│ │ │ │
│ └────────────────────────────┬────────────────────────────┘ │
│ │ │
│ localStorage ◀──┘ (redux-persist) │
└────────────────────────────────┼───────────────────────────────┘
│
HTTP │ Bearer token + JSON
│
┌────────────────▼─────────────────┐
│ │
┌───────┴────────┐ ┌────────┴────────┐
│ Real API │ │ Mock backend │
│ (your service) │ │ (json-server) │
└────────────────┘ └─────────────────┘There is one Redux store, one router, one ability instance, and one i18n instance per page load.
Layers, top to bottom
Coreola is organized into clear layers. Each layer depends only on the layers below it.
View layer (src/features/<area>/<page>/)
Pages and their views. Pure presentation — they render props and forward events. No state, no fetching, no redux subscriptions inside views.
Model layer (the page’s hooks/use<Page>Model.ts)
A single top-level hook per page. Holds all state, fetching, mutations, dialog flags, and side effects. Views consume its return value.
Routing layer (src/app/router.tsx + src/app/routes.ts)
A flat config (routes.ts) describes the full route tree. router.tsx walks it, resolves lazy components by importUrl, applies redirects, and filters by ability and feature flag at navigation time.
State layer (src/app/store.ts + src/slices/ + RTK Query slices)
A single Redux store combines:
- App slices (
app,breadcrumbs,user) - One RTK Query reducer per API resource
Data layer (src/api/<resource>/ + src/helpers/baseQuery.ts)
Each resource is one RTK Query createApi slice with its own endpoints and generated hooks. All slices share the same baseQuery factory.
Infrastructure layer
Theme, i18n, persistence, error boundary, snackbar provider — wired in src/App.tsx once at the root.
Bootstrap sequence
When the page loads:
src/index.tsxmounts<App />inside a Redux<Provider>and a<PersistGate>.redux-persistrehydrates the persisted state fromlocalStorage.src/App.tsxwraps the tree with the MUI<ThemeProvider>,<I18nextProvider>, the snackbar provider, and the error boundary.src/app/router.tsxbuilds a React Router data router fromroutes.ts.- The router resolves the matched route to a lazy component via Vite’s
import.meta.glob. - The lazy component (a page like
Details.tsx) renders, calling its model hook on first render. - The model hook subscribes to RTK Query endpoints, which trigger HTTP calls through
baseQuery. - As responses arrive, RTK Query updates the store; the page re-renders with data.
This sequence is the same for every page. Adding a new page does not change the bootstrap.
Routing in practice
Routes are declared as nested Route objects (see app/app.types.ts). Each route can declare:
path— the route segment (relative to its parent)importUrl— the lazy component path (resolved by Vite glob)redirectTo— turns the entry into a redirectabilityCan— required CASL abilities (e.g.['assessment.read'])featureFlagCan— required feature flag keyschildren— nested routesmenu,iconRef,group,title— metadata for the sidebar
router.tsx translates this config into a RouteObject[] for createBrowserRouter. The translation:
- Replaces
importUrlwith React Router’slazyfield - Turns
redirectToentries into data-router loaders that callredirect() - Performs runtime ability and feature-flag filtering at index-redirect time, so the user lands on the first sub-page they are allowed to see
The sidebar menu is built from the same routes.ts — filtered by ability/flag — by useGetMenuTree. This keeps navigation and routing coherent: if a route is allowed, it shows up in the menu; if not, it does not.
See Routing for full details.
State management
Coreola uses a single Redux store with two kinds of state:
Application state (src/slices/)
Hand-written Redux Toolkit slices for non-API state:
app— theme, language, sidebar collapse state, active requests counter, etc.breadcrumbs— the breadcrumb stack rendered by the dashboard layoutuser— current user, access token, abilities
Data state (RTK Query, src/api/<resource>/)
One RTK Query slice per backend resource. Each slice owns its cache, request lifecycle, and tag invalidation. Slices expose generated hooks (e.g. useGetAssessmentsQuery, useUpdateFindingMutation) that pages consume via their model hook.
Persistence
redux-persist is configured in reducers.ts:
- The root persist whitelist is
['user']— auth token, profile, abilities. - The
appslice has its own nested persist config that persists everything exceptactiveRequestsCount— so theme, language, and similar UI preferences survive reloads. - API caches are intentionally not persisted; they are refetched on next load.
A logout action wipes the persisted root and re-initializes everything except the app slice (so theme/language survive logout).
See State Management.
Data flow on a single click
Tracing a “click button → row updates” interaction:
- View dispatches a callback from the model hook (e.g.
onUpdateStatus(rowId, 'approved')). - Model hook calls an RTK Query mutation hook:
await updateStatus({ id, status }).unwrap(). - RTK Query wraps the call through
baseQuery:- Increments
activeRequestsCountfor any component that subscribes to global request activity - Adds
Authorization: Bearer <token>from the user slice - Sends the request to either
SYSTEM_APIorMOCKUP_APIbased on the slice’stargetAPI
- Increments
- On success, RTK Query updates its cache. Tags (e.g.
['Assessment', { type: 'Assessment', id }]) cause related queries to refetch. - On error,
baseQueryshows a snackbar throughsnackActions.error()and (for 401) triggers token re-validation. - View re-renders with new data.
This flow is identical across every feature. Pages do not write to Redux directly; they go through model hooks, which go through RTK Query.
API targets
Coreola supports two backends concurrently:
SYSTEM_API— the real production backendMOCKUP_API— the json-server mock backend
Each RTK Query slice picks one via getBaseQuery(path, targetAPI). This lets a feature in development hit the mock while the rest of the app talks to the real API.
The split is deliberate — it lets frontend work proceed before real endpoints exist, and it gives a known-good fallback for demos.
See API Layer.
Access control
CASL is used end-to-end:
- The
userslice stores an ability matrix —{ subject: { action: boolean } }— fetched at sign-in. buildAbilityFromMatrix()(inhelpers/rbac.ts) converts it to a CASLAbilityinstance.- The router uses the ability to filter routes before mounting them (no flicker of forbidden pages).
- Components use
useAbility()andability.can('action', 'subject')for in-page checks (e.g. hide a button). - Feature flags follow the same pattern via
featureFlagCananduseFeatureFlag().
Roles, abilities, users, and feature flags are themselves CRUD resources, editable from /application/accounts and /application/settings/feature-flags — provided the signed-in user has those permissions.
See Permissions.
MVVM in this codebase
Coreola enforces an MVVM convention because complex pages with it are far easier to reason about than complex pages without it.
Page (View)
↓ calls once
useDetailsModel() ← the Model
↓ exposes
{ assessment, findings, dialogs, handlers, ... }
↓ passed as props to
DecisionCardView, FindingsCardView, EvidenceCardView, ...Hard rules:
- View components must not call
useState,useDispatch, RTK Query hooks, or any side-effect hook. - The model hook must not render JSX.
- Per-feature subordinate hooks (
useCreateFinding,useUpdateEvidenceStatus) are allowed under the page’shooks/folder and called from the top-level model hook.
The Plop generator (mvvm-component) scaffolds this layout for you.
Internationalization
i18next is configured in app/i18n.ts with three chained backends:
- HTTP — fetches
public/locales/<lang>/<namespace>.jsonat runtime - Local storage — caches translations per-language for instant subsequent loads
- Optional remote backend if
VITE_APP_I18N_API_URLis set
Namespaces are derived from feature areas (the route’s namespace), so a page only loads what it needs. Components call useTranslation('<namespace>') and use t('key') directly — namespaces are never spelled out in keys.
Theming
Two theme objects (themes/light and themes/dark) are produced by a factory in themes/themes.ts. The active theme is stored in the app slice and switched at runtime — no reload, no flash. MUI’s <ThemeProvider> wraps the tree.
The app slice’s theme value is persisted, so a user’s preference survives reloads.
See Theme.
What lives at the root
A few cross-cutting concerns are mounted once in src/App.tsx:
<ErrorBoundary>— catches uncaught render errors<SnackbarProvider>— registerssnackActionsglobally forbaseQueryand the rest of the app<ThemeProvider>— wraps the app in the current MUI theme<RouterProvider>— the data router built fromroutes.tsPrivateRoute— bootstraps auth/meta/features and gates private routes
These are the only “ambient” infrastructure pieces. Everything else lives inside its feature folder.
Why these choices
- Redux Toolkit + RTK Query instead of Zustand/React Query: needed Redux DevTools, persistence, and a single store of truth across charts, dialogs, breadcrumbs, and tables.
- MVVM over hooks-only: pages with 5+ data sources and 3+ dialogs become spaghetti without a clear “model” hook.
- Config-driven routing instead of nested
<Routes>: the route tree drives navigation, breadcrumbs, ability-based filtering, and lazy loading from one source. - CASL instead of bespoke role checks: gives a small DSL for “can this user do this?” that is testable and shareable with the backend.
- MUI instead of headless components: design-system tokens, dark mode, and accessibility solved out of the box.
- Vite instead of CRA/Webpack: fast dev server, ES module–native, painless code-splitting.
Next steps
- Tech Stack — every library and what it does
- Routing — the route config in detail
- State Management — slices, persistence, RTK Query
- API Layer —
baseQuery, error handling, tag invalidation