Files
loyaly-merchant/ARCHITECTURE.md

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 import from a mock/ module in a component, hook or page. Server Components read through a *ServerRepository; clients go over HTTP.
  • Never call fetch in a component. shared/services/httpClient is 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:

  1. 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.
  2. 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.
  3. 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, and tsc --noEmit is 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. Never Card.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.