Back to Cherry Studio

Renderer Architecture

docs/references/renderer-architecture.md

2.0.028.0 KB
Original Source

Renderer Architecture

This is the canonical reference for how src/renderer/ is organized: directory responsibilities, dependency direction, and the rules that keep them enforceable.

Renderer code is organized along two orthogonal axestype (what kind of artifact it is) and domain (which business domain owns it) — with dependencies flowing strictly downward, and a closed top-level: no capability ever earns its own top-level directory.

1. Two Axes

AxisQuestion it answersValues
TypeWhat kind of artifact is this?page / component / hook / service / util / …
DomainWhich domain owns it?a specific business domain (chat, knowledge, agent, …) | shared (no single owner)
  • features/<domain>/ is a full row on the domain axis: it spans every type column for one domain (its own pages, components, hooks, services, utils). This is why a feature is "cross-cutting" — it cuts across the type buckets.
  • The top-level type buckets (components/, pages/, hooks/, utils/, …) are the cells of the shared row: they hold only the cross-domain / standalone remainder.
  • The meaningful comparison is cell-to-cell within a column (features/chat/components/ ↔ top-level components/), never features/components/ (a category error: a row is not a cell).

2. Layers & Dependency Direction

Four layers. Dependencies may only flow downward (1 → 2 → 3 → 4).

#LayerDirectoriesRole
1App / compositionwindows/, routes/, top-level pages/ (cross-domain shells only)Entry points, provider mounting, router, app shell; composes features
2Domainfeatures/<domain>/One business domain's vertical slice; mutually isolated from sibling features (consumed from above by the app layer)
3Shared (no single owner)components/hooks/ / services/utils/ / data/ / ipc/ / workers/; plus i18n/ / assets/ / types/Cross-domain reusable artifacts
4Primitivespackages/ui (@cherrystudio/ui), @shared, @loggerApp-agnostic foundation

Rules:

  • Within the type axis: window → page → component → primitive (UI composition; detailed in §2.1).
  • Along the domain axis: a domain row depends only downward — on the shared layer, primitives, and its own internals; it never imports a sibling domain row, and the shared layer never imports it (an upward edge). Its only legal consumers are therefore the app layer (windows/ / routes/ / top-level pages/): window → feature and page → feature are the legal inbound edges — a feature is built to be imported from above. Cross-domain needs route down (extract the shared piece into the shared layer) or up (the app layer composes both features), never sideways.
  • Inside the shared layer: components (UI) → hooks / services (behavior / runtime) → utils / data / ipc / workers (stateless helpers + infra foundation) → primitives. The foundation members (utils / data / ipc / workers) are co-equal and may import one another — a utils module calling data / ipc is a downward infra call, not an upward edge. No shared module renders into, or imports from, a higher layer (components / hooks / services / features / pages).

Why the two banned edges matter — both keep the dependency graph a strict downward DAG. shared → feature (an upward edge) would make a shared module secretly domain-coupled, open feature → shared → feature cycles, and pin the feature into the eager shared chunk (defeating per-feature code-split). feature → feature (a sideways edge) would leak one domain's blast radius into another, bind callers to internals the barrel (§5) declares unstable, and block clean deletion (features get reshaped/removed in v2). Both are banned as categories, not case-by-case, so one import/no-restricted-paths rule enforces them (§5; sources in §9).

2.1 Type-Axis Composition Chain

The type axis is a strict UI composition order: each kind composes the one below it and never imports the one above. It is orthogonal to the domain axis (§1) — a page may be domain-owned (features/<domain>/pages/) or a top-level shell, but its composition rules are the same either way. A feature-internal piece obeys the identical type-axis direction rules as its top-level counterpart; its one extra freedom is that it may import its own feature's siblings directly (internal cohesion needs no barrel — the §5 barrel is only the external door).

KindComposes / may importMust not import
windowrouter, app-wide providers, pages, features, shared, primitives— (imported by no one)
pagecomponents, feature content, shared, primitivesanother page, a window
componentother components, primitives, shared behavior (hooks / services / utils)page, window, features
primitivethird-party onlyany @renderer/* / app layer

Same-kind peering vs same-slice isolation — two senses of "same layer". Within one kind, peers compose freely: component → component, hook → hook, util → util are normal edges (a component is built from other components); the type axis only forbids importing up a kind (component → page/window/feature). Do not conflate this with the domain-axis rule that sibling features (the same slice layer, §2) may never import each other — component → component is allowed while feature → feature is not, because they sit on different axes. Two riders: (a) page is the one kind where same-kind peering is also banned (page → page, §7); (b) same-kind peering still obeys the domain axis — a shared component still can't reach up into a feature, nor a feature-A component sideways into feature-B (§2). service / util peering is allowed on the same terms but must stay acyclic.

Primitive requirements (packages/ui and @shared):

  • packages/ui (@cherrystudio/ui) holds app-agnostic UI primitives (Shadcn + Tailwind). It imports only third-party packages and never @renderer/*; it carries no business, domain, or data-layer knowledge.
  • @shared holds cross-process types, contracts, and pure logic, importable by both main and renderer and depending on no app layer. Cross-process is the entry gate, not a description: logic reachable from only one process stays in that process's own layer. For @shared's internal layout, its two invariants (cross-process; no mutable runtime state), and the closed top-level set, see Shared Layer Architecture.
  • Primitives are the leaves: everything may import them; they import no app code.

3. Directory Responsibilities

Target layout (in-flight directories pending migration are listed in §8):

text
src/renderer/
├── windows/      # App      — per-window entry roots (MainApp/SubWindowApp) + shell
├── routes/       # App      — route definitions
├── pages/        # App      — cross-domain shell pages only (domain pages live in features)
├── features/     # Domain   — one business domain per dir
│   └── <domain>/ #            index.ts (sole public API) + pages/ components/ hooks/ services/ utils/
├── components/   # Shared    — cross-domain, app-aware, presentational UI
├── hooks/        # Shared    — cross-domain hooks
├── services/     # Shared    — non-component singletons / runtime logic
├── utils/        # Shared    — cross-domain stateless functions
├── data/ ipc/ workers/  # Shared infra — data access, IpcApi bridge, web workers
└── i18n/ assets/ types/ # Shared — locale, static assets, cross-domain types

packages/ui (@cherrystudio/ui)  # Primitive — app-agnostic design system
src/shared                       # Primitive — cross-process types / contracts / pure logic
DirectoryResponsibilityMay depend on (downward)Must not
windows/Multi-window entry points; mount providers, router, shellevery lower layerbe imported by anyone
routes/Route definitions pointing at pagesfeatures, shared, primitivesbe imported by lower layers
pages/ (top-level)Only cross-domain shell / composition pages; domain pages move into features/<domain>/pages/features, components, shared, primitivesimport another pages/<page> (cross-page coupling)
features/<domain>/One business domain's vertical slice (its pages/components/hooks/services/utils); curated index.ts is the sole public entry. Its only legal importers are the app layer (windows/routes/pages), via the barrelshared layer, primitives, its own internals(1) import a sibling feature (2) be imported by the shared layer or a sibling feature (3) hold non-domain / cross-cutting / domain-agnostic infra
components/App-level shared UI: cross-page, no domain knowledge, app-aware, presentationalpackages/ui, other components, hooks, services, utils, @sharedimport features; import pages; own a domain's data flow
services/App-level runtime services: a module owning retained state / resources / lifecycle (a singleton capability — class + suffix, Naming §5.2), or a stateless module promoted out of utils/ by outward side effects or a forced dependency (routing procedure below). A multi-file topic forms services/<topic>/ behind a barrel (§3.1). Plain modules, no components or JSX. A stateless helper does not belong here merely for calling data / ipc reads — route it to utils/other services, utils, data, ipc, @sharedimport features; import pages; import components; render UI; call React hooks
hooks/Cross-domain reusable hooksother hooks, services, utils, data, @sharedimport features/pages/components; retain a domain's hooks once that domain has its own feature (§4.1)
utils/Cross-domain, stateless, domain-agnostic functions (queries, conversions, predicates, formatters) — may call downward infraother utils, @shared, data, ipc, workers, third-partyimport components / hooks / services or any higher app layer; own retained state; perform outward side effects (routing procedure below); render UI
data/, ipc/, workers/Foundational subsystems (data layer, IPC bridge, web workers)utils, @sharedimport features/pages/components
i18n/, assets/, types/App-global locale / static assets / shared types only; domain-specific entries move into the owning featurehold domain-specific content
packages/uiApp-agnostic design system (Shadcn + Tailwind primitives + generic composites)third-party onlyimport any @renderer/*

Routing services/ vs hooks/ vs utils/. Ownership first, then shape — run the tests in order and stop at the first hit:

  1. Ownership. A module consumed by exactly one owner co-locates with that owner — inside its feature, or as a private satellite in services/<topic>/ (§3.1) — and skips the shape tests below. Shape routing binds shared modules only.
  2. Renders JSXcomponents/ / pages/.
  3. Uses React lifecycle / state / contexthooks/.
  4. Owns retained module-level state / resources / lifecycleservices/, normalized to a class + singleton export with the Service / Manager suffix (Naming Conventions §5.2 — including what counts as state).
  5. Statelessutils/ by default. Promote to services/ (plain camelCase name, no suffix) only for one of two reasons, stated in the PR:
    • outward side effects — the module changes something outside its own scope (e.g. opens a window, writes the clipboard; canonical list in Naming §5.2 — logging does not count);
    • dependency-forced — it must import services/, which utils/ may not. (Needing to import hooks/ is never a routing reason — neither utils/ nor services/ may; it means a non-hook export is stranded in a hooks/ file — fix that upstream.)

Reads never promote: calling data / ipc to fetch or query keeps a module in utils/. The authoritative table is Naming Conventions §5.2. These top-level buckets hold cross-domain pieces; a small domain-specific piece may stay here until its domain earns a features/<domain>/, then it moves in (the §4.1 promotion rule).

Providers. A React context provider is a component, not a service — services/ holds non-component logic only. App-wide providers (theme, command, context-key, notification) live in the shared tier (they are components) and are mounted by windows/ (a downward window → component edge); domain-owned providers live in their feature. A provider's reusable, non-React logic belongs in @shared or services/, not in the provider component itself.

3.1 services/<topic>/ Topic Directories

A headless capability (no UI) that outgrows one file grows in place into a camelCase topic subdirectory — services/<topic>/ — holding its public face plus its private, topic-specific satellites (stateless helpers, per-instance classes, adapters, topic types). This is the middle step of the growth path services/<topic>.tsservices/<topic>/features/<domain>/ (§4.1), and the terminal form for capabilities that never grow UI. Existing residents: services/aiTransport/, services/import/, services/notification/. The main process applies the same rule (Main Process Architecture).

RuleMeaning
One barrel, sole entryexactly one curated index.ts (§5 topic-barrel rule); everything else is private to the topic
Satellites skip shape routinga topic-specific helper lives here even though its shape says utils/ — the §3 shape tests bind shared modules only; a generic helper (reads naturally with no topic context) still goes to utils/
Single consumer, or outa satellite stays only while this topic is its sole consumer; a second consumer moves it to utils/ (generic) or promotes it into the barrel (topic public API)
No UI, everno JSX and no React hooks inside; UI parts route into the shared buckets by shape (§3 / §6), and the domain promotes to features/<domain>/ only once the §4.1 trigger holds
Plain internal namesfiles drop the topic prefix (the directory carries it) — aiTransport/streamDispatchCoordinator.ts, not aiTransportStreamDispatchCoordinator.ts; the Service / Manager suffix still marks only stateful singleton classes (Naming §5.2)

4. features/ Definition

A features/<domain>/ is a self-contained business-domain module — a full row on the domain axis that co-locates the pages, components, hooks, services, and utils for one business domain in a single tree, exposing its public API through a curated index.ts.

Self-contained describes internal cohesion (all of one domain's parts live in one tree), not external unreachability: a feature is openly imported from above by the app layer (§2). It is isolated only horizontally — from sibling features.

  • Promotion, not default. A domain earns a features/<domain>/ home only once it is large and multi-file; a small domain stays as single files in the shared buckets. Do not pre-create a feature for an anticipated module. See §4.1 for the operational trigger and a worked example.
  • Business domains only. Cross-cutting capabilities (e.g. a command/keybinding system), domain-agnostic infrastructure (data, ipc), and the app shell do not live in features/.
  • Closest industry match is bulletproof-react's features/ (a self-contained domain folder). It is not FSD's fine-grained "feature" (a single business action) and not Nx's type:feature (a role that splits a domain across typed libs).

4.1 Promotion Rule — when a domain earns a feature

Promotion is lazy and per-case (§4), not a default — but it is a real path, not a directory doomed to stay empty: the rules above describe what the destination looks like. Until a domain qualifies, its pieces legitimately sit in the shared type-buckets (pages/<domain>/, components/<domain>/, hooks/<domain>/, …). (No features/ directory exists yet — see §8.)

Operational trigger (guidance, not a hard gate) — promote when all hold:

  • the domain already owns its own page(s) plus a multi-file spread of components/hooks/services across several shared buckets;
  • those pieces are imported mainly within the domain — broad cross-domain reuse is instead the signal to push a piece down into the shared layer, not into a feature;
  • folding them behind one barrel would shrink cross-bucket coupling, not merely relocate it.

Worked example — chatfeatures/chat/:

text
# scattered today (shared type-buckets)        # promoted
pages/home/           chat page shell           features/chat/
components/chat/      ~288 files          →       ├── index.ts      # curated public API (named exports, no export *)
components/composer/  ~119 files                  ├── pages/        # ← pages/home
hooks/chat/                                       ├── components/   # ← components/chat + components/composer
services/…            chat-only services          ├── hooks/        # ← hooks/chat
                                                  └── services/     # ← chat-only services

After promotion: the app layer (windows/routes/pages) imports @renderer/features/chat's barrel; nothing reaches into its internals (§5); and cross-surface runtime that other domains also use (e.g. the AI-stream transport, now at services/aiTransport) stays in the shared layer, not inside the feature.

5. Public API & Boundary Enforcement

  • Single entry. Each feature exposes exactly one curated index.ts (explicit named exports, no export *). External consumers import the barrel; reaching into a feature's internal files is forbidden. Barrel rules — including no nesting and enforced-entry-or-no-barrel — are the cross-process set in Naming §6.4; this is its feature-tier application. (VS Code applies the same rule: one contribution may import only another's single public common/ API, never its internals.)

  • Lazy loading goes through the same door. Dynamic import() obeys Naming §6.4 rule 2 like any import: React.lazy(() => import('@renderer/features/chat').then(m => ({ default: m.ChatPage }))) — map the named export at the call site rather than deep-importing an internal file for its default export. Only a feature's own code may lazy-load its internals.

  • Component directories. A single-file component stays flat (components/Foo.tsx, no directory). Promote to a directory only when it owns private satellites (sub-components, hooks, helpers, styles); the main implementation is then a named file, and the directory exposes a barrel that closes the satellites off. The barrel is index.tsnever index.tsx: a re-export has no JSX, so a component's Foo/index.tsx is the classic double mistake here (implementation and wrong extension). The index name is reserved for the barrel (Naming §6.4):

    components/Foo.tsx          # single-file → flat, no directory
    components/Bar/             # multi-file → named impl + barrel door
      Bar.tsx                   #   main implementation (never index.tsx)
      components/BarRow.tsx     #   private satellite, closed off by the barrel
      index.ts                  #   .ts, not .tsx (re-export has no JSX): export { Bar } from './Bar'
    
  • Shared buckets carry no root barrel. types/, utils/, and services/ are categories, not modules: each has no root index.ts — consumers import the specific file or topic (@renderer/types/<topic>, @renderer/utils/<topic>, @renderer/services/<topic>), never the bucket root. A multi-file topic subdirectory exposes exactly one curated index.ts (named exports, no export *) and keeps its other files private; a single-file topic stays a flat <topic>.ts and is promoted to a subdirectory only when it actually owns multiple files. This mirrors Shared Layer Architecture §3.1 one-for-one — same rule, the bucket merely lives under @renderer/* instead of @shared/*.

  • Mechanical enforcement. Boundaries are enforced by lint, not by convention alone. The import/no-restricted-paths zones are configured: components/hooks/utils/services may not import features/pages; pages may not import another pages; packages/ui may not import @renderer/*. The shared-layer edges are enforced at error; the sibling-page (pages → pages) edges remain at warn pending features-ization.

6. Top-Level Governance

The top level is a closed set of categories, not an open list of modules. A new capability is placed inside an existing category by decomposing along the type axis; it never earns a new top-level directory.

This is the renderer-specific application of Naming Conventions §4.8 (top-level directories are closed by default): a capability fails §4.8's necessity test because existing buckets can host it by decomposition.

Corollary — capabilities decompose, they do not relocate as a blob: route each part by its shape (§3) — non-component logic → services/ or utils/ per the §3 routing (or @shared/ if cross-process), React providers and UI → components/, hooks → hooks/, types → @shared/. Nothing is added to the top level.

This is why a command/keybinding/menu system is not a feature and not a top-level directory: it decomposes by shape across existing homes, one cell per type:

PartNatureHome
keybinding definitions + resolution, context-expr eval, menu resolution, ContextKeyService/MenuRegistry blueprintscross-process pure logic + class blueprints@shared/utils/command
command / keybinding / menu typescross-process types@shared/types/command
shortcut-label, KeyboardEvent → binding, display-state helpersrenderer-only pure logicutils/command
context objects + their accessor hooks, useResolvedCommand/useResolvedCommandMenu, useCommandShortcutsReact contexts + hookshooks/command
CommandProvider/CommandContextKeyProvider, CommandMenus, CommandControlsReact componentscomponents/command

A Provider returns JSX so it is a component; the contexts it fills and the hooks that read them are non-JSX and sink one tier below to hooks/command; pure logic sinks to utils/command (renderer-only) or @shared/utils/command (cross-process), and types to @shared/types/command. Nothing goes to services/, and @shared keeps only what both processes use — a resolver consumed only by the renderer (e.g. getCommandShortcutLabel) belongs in utils/command. After decomposition every edge is downward (component → component/hook, hook → hook); the former component → feature and hook → feature inversions are gone (the importing component/hook are the shared buckets — a feature-internal component importing its own siblings is not such an inversion), and nothing is a "feature".

7. Anti-Patterns

  • A shared bucket (components//hooks//utils/) importing features or pages (a reverse / upward edge).
  • pages/X importing pages/Y (cross-page coupling).
  • Domain-specific artifacts left in a top-level type bucket (backup managers, model/provider widgets, etc.).
  • Treating a cross-cutting capability as a peer feature.
  • Opening a new top-level directory for a single capability.
  • A feature using export *, or an external consumer deep-importing a feature's — or a services/<topic>/'s — internals.
  • Module-scope mutable state behind a plain camelCase name — retained state must take the class + singleton + suffix form (Naming §5.2); a plain name asserts statelessness.
  • Importing a shared bucket root (@renderer/utils, @renderer/types) instead of the specific file/topic, or giving types//utils/ a re-export root index.ts (§5).
  • A hand-rolled components/layout/ bucket — "layout" is not a layer here: route layouts live in routes/ (TanStack layout routes), layout primitives (Box/Stack/Grid) in packages/ui, app shell in windows/.

8. Target vs Current State

This document describes the target architecture. The renderer has not yet been fully migrated to it; the remaining gaps below are known and tracked. Migration is deferred and intentionally out of scope here.

Only outstanding deviations are tracked here: once a deviation is resolved it stops violating the target, so it is dropped from this list rather than recorded as done. The table lists definite mis-classifications and structural violations that remain. A small domain's pieces (components, pages, hooks, services, utils) may legitimately sit in the shared type-buckets until that domain earns a features/<domain>/; that promotion is a separate per-case judgment (§4.1) and is not prescribed here. A per-file naming-suffix audit of services/ against Naming §5.2 (v1-era pseudo-Service function collections, stateful plain-named modules) is likewise out of scope here.

AreaCurrent stateTarget
App shellshell chrome in components/layout/ is partly window-specific, partly cross-window — including AppShell and the Sidebar it renders (components/app/Sidebar, imported by components/layout/AppShell.tsx — window-shell UI, not dead code)decompose by ownership: main shell (AppShell, AppShellTabBar, tab drag, Sidebar) → windows/main/; sub-window chrome (SubWindowControls, SubWindowTitle) → windows/subWindow/; cross-window building blocks (TabRouter, TabIcon, titleBar, tab icons) → shared components/ (e.g. components/shell/). No new windows/shell/ bucket
Cross-page imports~13 pages/<domain>/ files import each other (pages → pages coupling), held at warn by the §5 gatea page must not import another page; route shared needs through the shared layer, then tighten the gate to error
utils/message/ topic barrelutils/message/ is a multi-file topic subdir with no index.ts (the @renderer/utils root barrel has already been dropped)give utils/message/ one curated index.ts (named exports, no export *)
Domain promotionlarge multi-file domains (chatpages/home + components/chat + components/composer; knowledgepages/knowledge + …) are scattered across the shared type-buckets, and no features/ directory exists yetpromote the largest domains into features/<domain>/ per the §4.1 trigger (chat and knowledge first)

9. Industry References

ClaimSource
Unidirectional dependencies; no cross-feature importsbulletproof-react — docs/project-structure.md
Same-layer slices cannot import each other, so a widely-depended-on module must sit on a strictly lower layer; shared is the lowest layerFeature-Sliced Design — reference/layers, reference/public-api
Tag cross-cutting capabilities as a lower type (type:ui/util) and enforce direction with lintNx — enforce-module-boundaries
Command/keybinding services live in the platform/ foundation layer; feature contributions are isolatedVS Code — Source Code Organization
A domain-agnostic, non-differentiating capability is a generic subdomain, not a peer of core domainsDDD strategic design
App-wide singletons live in Core; features do not import each otherAngular — Core / Shared / Feature modules