5.8 KiB
Architecture
How this codebase is organised, and the rules that keep it that way. Read this before adding a feature; it should take about five minutes.
The layers
Data flows in one direction, and each layer is allowed to know only the one below it:
component / page renders state, owns no rules
↓
features/<f>/hooks binds a service or repository to React
↓
features/<f>/services domain rules: validation, interpretation, defaults
↓
features/<f>/repositories TRANSPORT ONLY — the single place that knows a URL
↓
app/api/<f>/route.ts HTTP boundary: authorises, parses, responds
↓
features/<f>/mock/*.mock fixtures, server-side only
Connecting a real backend touches the repository layer and nothing else.
Point NEXT_PUBLIC_API_BASE at a host, or rewrite the four lines in a
repository, and every component above it is untouched. That is the property the
whole structure exists to protect, so:
- Never
importfrom amock/module in a component, hook or page. Server Components read through a*ServerRepository; clients go over HTTP. - Never call
fetchin a component.shared/services/httpClientis the only module that does. - A repository never interprets a response, and a service never builds a URL.
Where things live
src/
app/ routing only — thin files, no logic
(public)/login unauthenticated routes → PublicLayout → GuestGuard
(workspace)/… authenticated routes → ProtectedLayout → AuthGuard
api/ route handlers
proxy.ts the server-side auth gate (Next 16 renamed middleware → proxy)
features/<feature>/
components/ UI for this feature only
hooks/ data access + view state
services/ domain rules, framework-free, unit-testable
repositories/ URLs and verbs
types/ the wire contract for this feature
mock/ fixtures (server-side)
utils/ · config/ · guards/ · providers/ as needed
shared/
components/ brand · charts · patterns · primitives · scope · data · motion
hooks/ useResource · useScope · useBreakpoint · usePersistentFlag
layouts/ ProtectedLayout · PublicLayout · workspace shell
providers/ app-wide state (workspace scope)
services/ httpClient (transport) · apiRoute (handler helpers)
types/ the response envelope
mock/ seeded RNG shared by every fixture
theme/ design tokens; edit loyalyTheme.ts, run `npm run theme:build`
A feature owns everything about itself. If two features need the same thing, it
moves to shared/ — it does not get imported across features, with one
deliberate exception: features/dashboard/types holds the analytics primitives
(TimePoint, ActivityEvent) that LYTs and Stores genuinely share, because
duplicating them would let two modules disagree about the same wire shape.
Authentication
Three independent gates, in order of authority:
src/proxy.ts— runs before any protected route renders. No valid session cookie, no page. API paths get a 401 JSON; pages get a redirect to/login?next=…. This is the one that matters.requireApiSession()— every data route handler calls it. Next's own docs warn that proxy coverage can be silently lost by a matcher change or a route move, so the check is repeated where the data is.AuthGuard/GuestGuard— client-side. Covers what the server never sees: a session expiring in an open tab, a logout in another tab, a client-side navigation. Not a security boundary.
The session is a signed httpOnly cookie (features/auth/services/sessionToken),
so the browser cannot read or forge it. SessionProvider holds only what to
draw, never what to permit — it asks GET /api/auth/session and believes the
answer.
Swapping the mock for a real identity provider means changing
verifyCredentials in features/auth/mock/users.mock.ts and the three URLs in
authRepository. Nothing else, including the login form, is aware.
Conventions
- No
any. The codebase has zero, andtsc --noEmitis expected to pass before every commit. - Components stay under ~300 lines. When one grows past that, the split is usually behaviour-into-a-hook, not markup-into-more-markup.
- Names say what the thing is:
DashboardKpiCard,StaffAttendanceTable. NeverCard.tsx,utils.ts,NewFile.tsx. - Styling comes from the design system, in this order: component props →
token-backed Tailwind utilities → a theme override in
loyalyTheme.ts. There are no per-feature CSS files; see the note below.
On CSS
The brief that produced this refactor asked for per-feature stylesheets
(dashboard/styles/kpi.css, and so on) so that changing one module cannot
affect another. That isolation is already total here, by construction rather
than by convention: styling lives in component props and utility classes scoped
to the element they are written on, so there is no selector in the codebase
that can reach another feature. Adding stylesheets would introduce global
selectors — the one mechanism that can leak — and they would have to fight
StyleX's @layer astryx-base for the cascade. Shared visual decisions belong in
theme/loyalyTheme.ts, which is the single place a change is meant to be
system-wide.
If a future component genuinely needs CSS that props and utilities cannot
express, astryx swizzle <Component> ejects the source for that one component
rather than opening a stylesheet the whole app can be edited through.