0020 — Frontend layering: where a file goes in a Next.js app
Every Next.js app in frontend/ uses one layered structure — routes, component tiers, hooks, services, logic, util, const, type — with the client/server split carried by the folders.
Decided. Every Next.js app in frontend/ uses the same layout, the one already running
in tool-app, building-app, and devops-app. A file's home is decided by what kind of
thing it is, not by which feature it belongs to:
| Layer | Holds | Rule |
|---|---|---|
app/ | Routes only | A page.tsx picks a view and passes route params. No UI, no fetching |
src/component/base/ | Primitives — button, dialog, image wrapper | No domain knowledge, no fetching |
src/component/partial/ | Composites reused across views — cards, drawers, app chrome | Presentational; the parent owns behavior |
src/component/layout/ | The shell a route tree renders inside | One per shell. DashboardLayout, not AppShell |
src/component/view/ | One screen, mounted by one route | Owns the screen's state and calls services |
src/hook/provider/<Name>/ | React context | <Name>Context.tsx, <Name>Provider.tsx, use<Name>.ts, index.ts |
src/hook/<concern>/ | Everything else stateful | useX.ts. Never a bare hook file at src/ root |
src/service/<domain>/ | Reaching anything over HTTP | Default-export object of async methods; fetch lives in <Domain>ServiceBase.ts |
src/service/<domain>/server/ | Server-only data access | <Name>ServiceServer.ts, first line import "server-only" |
src/logic/ | Business rules with no React and no HTTP | Pure functions, callable from either side of the boundary |
src/util/ | Generic helpers — format, parse, cookies | <Name>Helper.ts, default-export object |
src/const/ | Values with no behavior | Named exports |
src/type/ | Types not owned by a service module | Domain subfolders; style/*.d.ts for MUI augmentation |
Component mechanics, which are as load-bearing as the folders:
- A component is a folder,
Name/Name.tsx, plusName.module.scsswhen it is styled andindex.tsalways. Child components go inName/component/Child/, recursively. index.tsre-exports the default under its own name —export { default as Name } from "./Name". A folder of children gets acomponent/index.tsofexport * from "./Child"lines. Imports therefore always name a folder, never a file.function Name() {}thenexport default Nameat the bottom. Notexport default function."use client"goes on the component that needs it, not onpage.tsx. Pages stay server components so they can be async and hold metadata.- SCSS modules sit beside the component and read tokens from
styles/scss/theme.module.scss, the one file that also:exports them to TypeScript.
What this changes today
local-app was written flat: src/component/AppShell/ holding the shell, the side nav, the
drawer, and a context; src/component/WaveMenuButton/; and nav.ts, sql.ts, serviceBus.ts,
useSnapshot.ts, server/postgres.ts loose at src/ root. Its sibling devops-app, in the
same repository, already had the structure above. The move:
| Was | Is |
|---|---|
component/AppShell/AppShell.tsx | component/layout/DashboardLayout/DashboardLayout.tsx |
component/AppShell/SideNav.tsx | component/layout/DashboardLayout/component/Navigation/ |
component/AppShell/NavDrawer.tsx | component/partial/NavigationDrawer/ (chrome) + component/partial/DrawerNav/ (links) |
component/AppShell/workspaceLoading.tsx | hook/provider/Workspace/ |
component/WaveMenuButton/ | component/partial/WaveMenuButton/ |
src/nav.ts | src/const/nav.ts |
src/sql.ts, src/serviceBus.ts | src/service/local/{DatabaseService,BusInspectorService}.ts |
src/server/postgres.ts | src/service/local/server/PostgresServiceServer.ts |
src/useSnapshot.ts | src/hook/serviceBus/useSnapshot.ts |
200-line app/service-bus/page.tsx | component/view/ServiceBus/ behind an 11-line page |
fetch calls that were inline in views moved behind DatabaseService and BusInspectorService.
That is the layering doing work rather than a rename: the SQL view had response-shape checks and
error-string fallbacks copied at three call sites, and they collapsed into one place.
Alternatives
- Let each app choose, and only require internal consistency. Rejected on the evidence that
produced this ADR.
local-app's shell is a port ofdevops-app's, and the port had already drifted — one file where there were four, a hand-rolled loading context where a provider existed,AppShellwhere every other app saysDashboardLayout. The next port would have been from whichever app the author happened to open. - Group by feature (
src/serviceBus/{component,hook,service}). Genuinely better for deleting a feature whole, and it is what we would likely pick for a greenfield app with no siblings. Rejected because three apps already ship the layer-first structure and the shell is copied between them; a fourth arrangement makes every future port a translation. - A shared
frontend/commonpackage for the shell. Rejected as premature for the same reasoncommon/exists for Go only once a second caller does (0018): the shells differ in what the top bar holds and what the drawer knows, and extracting them now would encodedevops-app's CMS-shaped nav into an app that has two static links. Revisit when two apps need the same shell, not a similar one. components/,hooks/,services/(plural). Rejected only because the existing apps are singular. Nothing else recommends either.
Why
Three of the four Flow frontends independently arrived at this structure, which makes it the convention whether or not it is written down. Writing it down buys two things: a port stops being a judgement call, and "where does this go?" has an answer that does not depend on which app you learned first.
The layer boundaries also carry the client/server split, which is the one mistake in a Next.js
app that is expensive rather than untidy. src/service/<domain>/server/ marked with
import "server-only" is the difference between a build error and shipping a database password
to a browser bundle.
What it rests on
- Nothing enforces any of this. ESLint in these apps configures quotes and semicolons; it has no rule about folders, import paths, or barrels. A reviewer noticing is the whole mechanism, which is the same position 0019 ends in.
- The reference apps are not perfectly consistent with each other.
building-apphas a view-locallogic/folder in one view and loose.tsxfiles at another view's root;tool-apphas child components with noindex.ts. Where they disagree, this ADR picked the majority form; those files are the minority, not a second sanctioned pattern. local-appis the only app in this repository that follows this fully.devops-appmatches on structure but has noview/layer yet, because it has no views — its routes render placeholders. The claim "three apps agree" is abouttool-appandbuilding-appin their own repositories, read at the time of writing, not about CI-verified state here.- The
base/tier is unpopulated in this repository. It is in the table because the reference apps have it and a component will eventually need it, not because anything here demonstrates it.
Consequences
- A
page.tsxthat grows UI is a defect, not a style preference. The Service Bus screen was 200 lines of view inside a route file and nothing flagged it. - Every
fetchfrom a component becomes a service method. This is the rule most likely to be skipped under time pressure, and the one that pays off, because the response-shape handling is what gets copied. - Adding a context means four files. That is heavier than a
useStatein the shell, and it is what makes the provider testable and reusable instead of private to one layout. - New frontend apps start from
frontend/seed/(theme stack and agent skill) and copy shell/service patterns fromdevops-apporintelligence-app. When the platform generator arrives it generates this shape; until then it is copied by hand and nothing checks it.
0019 — The handler layer: shared, generated, or hand-written
How a piece of handler code varies between services decides whether it is shared in common/, generated from the entity spec, or written by hand.
0021 — Datadog log search in the DevOps console
The operations console reads retained logs from Datadog through a devops-service proxy, because the Application key cannot reach a browser and every query must stay inside one environment.