DocumentationDecision recordsADR-0055
Workspace module boundaries
apps/workspace/src holds 23 feature folders next to shell, commands, store, bridge (now platform), contracts, and shared.
accepted
Context
apps/workspace/src holds 23 feature folders next to shell, commands,
store, bridge (now platform), contracts, and shared. Nothing states what each folder
owns or which folder may import which. Features reach into each other's
subfolders, commands and store/actions implement feature behaviour, shared
code imports the shell, and features import each other in cycles. The workspace
module restructuring milestone (#442 to #463) moves code between these folders
and needs one set of rules to move it towards.
Decision
Layers
Every source file belongs to one module, and every module belongs to one layer.
| Layer | Folder today | Module | Owns |
|---|---|---|---|
| app | main.tsx, app.tsx, app-route.ts, app/ |
one | Startup, providers, routing, binding platform ports such as the browser sync session, and registering each module's commands. |
| shell | shell/ |
one | Application chrome and layout: panes, tabs, split view, title bar, window controls, the compact and mobile shell. No note, sync, or persistence behaviour. |
| feature | features/<name>/ |
one per folder | One user-facing capability, such as editor, journal, sync, or lock, including its UI, models, behaviour, and command definitions. |
| commands | commands/ |
one | Command infrastructure: registry, bindings, frecency, hints, and the palette UI. No feature behaviour. |
| store | store/ |
one | The renderer store: state shape, selectors, subscriptions, and genuinely global state. |
| platform | platform/<name>/ |
one per folder | Runtime adapters. ports holds contracts shared by the adapters, browser the storage worker, OPFS, sync driver, service worker, and files, desktop Tauri IPC and windows, and runtime runtime detection, adapter selection, and the typed command client. |
| shared | shared/<name>/ |
one per folder | Domain-neutral UI, hooks, icons, and helpers. |
| contracts | contracts/ |
one | Types shared across layers. |
Dependency direction
A module may import from its own layer and the layers listed for it.
| Layer | May import |
|---|---|
| app | every layer |
| shell | feature, commands, store, platform, shared, contracts |
| feature | other features, commands, store, platform, shared, contracts |
| commands | store, platform, shared, contracts |
| store | platform, shared, contracts |
| platform | shared, contracts |
| shared | other shared modules, contracts |
| contracts | nothing |
Nothing imports app. Features import the command registry to define their
commands; app composes those definitions. Store may import platform because
renderer actions submit WorkspaceOperation messages through the bridge.
Modules must not form cycles. Because the layer order above has no cycles, a cycle between modules either crosses a forbidden edge or stays inside the feature or shared layer.
Public API
The files directly in a module's root folder are its public API. Folders inside
a module are private to it. Another module may import features/editor/note-editor.tsx
but not features/editor/vim/vim-motions.ts. Inside a module, any file may import
any other file, across its folders.
There are no index.ts barrels. To expose something from a private folder,
either keep its file in the module root, or add a root file named after the
capability that re-exports only what other modules use, for example
features/editor/markdown.ts re-exporting from features/editor/markdown/.
platform/runtime/runtime.ts is the one place that detects whether the
renderer runs in the browser or the Tauri shell and routes each command to the
matching adapter. Detection stays in platform rather than app because modules
read it while they load, before startup runs. Inside platform, runtime may
import browser, desktop, and ports; browser and desktop import only
ports, so the adapters never ask which runtime they are in. Their callers do.
The same rule applies to the single-module layers. store/actions/ is therefore
private to store; #448 dissolves it. Each shared/<name> and platform/<name>
folder is its own module, so shared/icons/animated/ is private to shared/icons.
Flat modules and capability folders
A module stays flat while it does one thing and every root file is meant for its
consumers. It introduces capability folders when it mixes several capabilities,
when file names start repeating a prefix (annotation-*, vim-*), or when it
has helpers other modules must not reach.
- Name a capability folder after what it does, such as
vim,markdown, orblocks, never after a file kind such ascomponents,hooks, orutils. - Use one level of capability folders. Nest further only when a capability is itself several capabilities.
- Drop the prefix the folder already supplies:
annotations/menu.tsx, notannotations/annotation-menu.tsx. - Keep the module's composition files, such as
note-editor.tsx, in the root.
Enforcement
__tests__/apps/workspace/src/module-boundaries.test.ts scans apps/workspace/src
and runs in the workspace Vitest project, so the desktop gate runs it. It
reports four kinds of violation, one line each:
layer: an import from a layer the importer may not use.internal: an import of a file inside another module's private folder.module-cycle: an allowed module edge that lies on a cycle between modules.file-cycle: a static value import that lies on a cycle between files. Type-only and dynamic imports cannot cause evaluation-order cycles and are left out of this check.
Static, type-only, side-effect, and dynamic imports, and new URL(..., import.meta.url)
worker entries, all count for layer and internal. Only @/ and relative
specifiers are followed.
Existing violations are listed in
__tests__/apps/workspace/src/module-boundaries.baseline.txt. The test fails on
a violation missing from the baseline, and fails when the baseline lists a
violation that no longer exists. After removing violations, shrink the baseline
with:
bunx vitest run --project workspace -u __tests__/apps/workspace/src/module-boundaries.test.ts
-u cannot add lines, because the check for new violations runs first. Adding a
line by hand is an exception that a reviewer has to accept. When moving a file
that the baseline names, replace its lines in the same change instead of adding
new dependencies.
Oxlint's import/no-cycle has no baseline, and no Oxlint rule expresses layers
or private folders, so the check is a test with no new dependency.
Consequences
- The baseline starts with 241 lines: 81
layer, 118internal, 35module-cycle, and 7file-cycle. The largest groups are features importing the shell,commands/workspace-commands.tsximporting features, modules importingstore/actions/, and the cycle running through editor, settings, AI, and journal. The restructuring issues remove them, and each change shrinks the baseline. - New code cannot add a layer violation, reach into another module's private folder, or close a cycle without editing the baseline.
- The scanner reads import statements with regular expressions. It relies on
oxfmt putting each
importandexport ... fromat the start of a line.
On this page