name: collapsible-feature-root-architecture description: 用于定义或重构基于 feature 的代码组织架构,尤其适用于判断当前作用域应保持单 feature root、何时引入 features/ 聚合层、何时拆稳定子 feature、何时严格限制 shared 目录、如何处理可被其他 workspace 源码依赖的 package/SDK/shared library 的导入 alias,以及如何分别处理包内部结构与仓库级组织边界。
可折叠 Feature-Root 架构
概述
采用一种按复杂度渐进展开的 feature-first 组织方式,而不是默认铺满整套目录模板。
这里要先明确一个前提:
L系列只描述包内部或应用内部的结构模型monorepo不是L级别之一,而是一套独立的仓库级组织规范- 两者是正交关系,可以组合,但不应混成同一条复杂度轴
核心原则:
feature是一个语义单元,不是强制目录名。- 如果当前作用域只有一个主 feature,那么当前目录本身就是这个 feature root。
- 只有当前作用域下出现多个并列 feature,才引入
features/作为聚合层。 - 只有某个 feature 内部出现多个稳定子业务域,才继续引入下一层
features/。
这样做的目标是:
- 低复杂度项目保持最小结构
- 高复杂度项目沿同一套规则自然生长
- 不让目录模板本身变成新的复杂度来源
作用域模型
同一套判断规则可递归应用在每一层作用域:
- 仓库
- 应用 / package
- feature
- 子 feature
每一层都先回答同一个问题:
- 当前作用域里有几个稳定的并列业务域?
- 当前目录是否可以直接作为一个 feature root?
- 是否真的需要显式增加一层
features/?
包内通用规则
在进入 L1、L2、L3 之前,要先明确一件事:
- 通用职责目录白名单
shared/的准入条件shared/的内部结构规则- feature 的唯一导出入口
- 平台差异层的唯一导出入口
- 目录白名单与弱语义目录禁止规则
这些都不是只属于某一个等级的局部规则。
它们默认是包内部结构的通用规则,适用于 L1、L2、L3;区别只在于:
- 某个等级是否真的需要用到某个目录
- 某个等级是否已经出现对应的结构轴
- 某些规则在低等级场景下可能暂时“不启用”,而不是“不成立”
因此不要把 L3 理解成“只有到了前端多平台才开始有这些规则”。
更准确地说:
L1、L2、L3共用同一套包内治理原则L3只是这些规则在前端多平台场景下的最完整展开形态- 前面的等级更像是
L3的子集或裁剪态,而不是另一套独立规范
包内复杂度分级
L1:Minimal Package
适用条件:
- 当前 package 还不需要
features/、shared/、platforms/ - 额外加一层骨架目录只是在制造空壳目录
- 当前结构更适合保持最小包内组织
推荐结构:
src/
├── app/ # 可选,仅装配层使用
├── index.ts
├── services/
├── managers/
├── configs/
├── repositories/
├── controllers/
├── types/
└── utils/
这里不要再额外创建 features/、shared/ 或 platforms/。
补充说明:
L1允许app/,但app/不是必须项L1允许根下按需出现固定通用职责目录L1的src/根目录原则上只保留边界文件与固定职责目录,不保留随意散落的角色实现文件- 这条规则不是只适用于 package 根;任何显式 scope root 都应默认遵守同一原则:root 保边界,角色文件回角色目录
- 边界文件例如
index.ts或少量 package 级装配入口;如果某个文件本质上是service、store、utils、config、types等角色实现,它应进入对应职责目录 - 不要把某个抽象概念名直接提升为新的顶层目录名;若它本质仍属于既有职责角色,应回到已有职责目录表达
- 不要再造一个单独的
<business-root>/目录把全部实现包起来 - 如果该
L1作用域本身是前端展示层项目,那么presenters/、managers/、stores/不是可选项,而是必备 owner 目录 - 如果某个 Electron workspace 同时包含前端展示层与主进程壳层,不要把两者混在同一个
src/root;前端展示层应保留在src/并按前端 owner 规则组织,主进程壳层应进入独立 root(例如electron/),再分别用各自的 contract 约束 - 注意:这只是示意结构,不代表所有目录都必须出现
L2:Single-Platform Multi-Feature
适用条件:
- 当前作用域下已经存在两个及以上稳定业务域
- 继续把它们并排放在根目录,会让边界开始模糊
- 同时已经出现稳定共享层,但平台差异还不是一等结构事实
推荐结构:
src/
├── app/
├── features/
│ ├── auth/
│ ├── notes/
│ └── settings/
└── shared/
这里要明确:
app/是装配、路由、provider wiring、启动级编排features/是主业务轴shared/是跨 feature 的稳定共享抽象- 如果某个 feature 内部继续长出多个稳定子业务域,可以在该 feature 内部递归继续使用同一套规则
L3:前端多平台
适用条件:
- 同一个前端 app 或 package 需要同时承载多个真实平台
- 平台差异已经大到不能只靠少量适配器解决
- 这些差异是长期存在的,而不是短期实现细节
推荐结构:
src/
├── app/
├── features/
│ ├── chat/
│ ├── auth/
│ └── settings/
├── shared/
│ ├── components/
│ ├── hooks/
│ ├── lib/
│ └── types/
└── platforms/
├── desktop/
├── pwa/
└── web/
这里要明确:
features/是主业务轴,业务能力优先放在这里shared/是稳定共享层,只容纳通用职责抽象,不容纳业务域目录platforms/是平台差异层,不是第二套features/- 默认应先共享业务实现,只有平台专属适配、桥接、provider wiring 与平台 API 接入才进入
platforms/ platforms/<platform>/根下应只出现通用职责目录,不应出现业务目录名L3不是新增一套专属规则,而是把包内通用规则与平台差异规则同时展开的全集场景- 如果该
L3作用域是前端展示层,那么 presenter-manager-store 三层 owner 也必须真实存在;只是它们不一定平铺在应用根,而应落到app/、具体 feature root、或平台作用域中各自拥有视图编排的地方
只有当前端平台差异已经成为稳定的一等边界时,才进入 L3。
不要因为少量 UI 差异或单个入口函数不同,就过早升级到 L3。
展开规则
什么时候保持“当前目录就是一个 feature root”
当以下条件大体成立时,应保持当前作用域为一个 feature root,不额外增加 features/:
- 只有一个主业务能力
- 从当前根目录出发仍然容易导航
- 大多数改动仍然集中发生在同一业务域内
- 如果拆目录,收益主要只是“看起来更整齐”,而不是真正降低维护成本
什么时候引入 features/
只有满足以下任一信号时,才应在当前作用域引入 features/:
- 已经出现两个及以上稳定并列业务域
- 这些业务域可以被清晰命名,而不是临时切分
- 变更通常只触及其中一个域,而不是总是成组修改
- 根目录开始混入互不相干的业务代码
什么时候引入子 feature
只有满足以下条件时,才应在某个 feature 内继续引入下一层 features/:
- 父 feature 内已经形成多个稳定子业务域
- 每个子域都有自己的一组状态、编排、页面或组件面
- 继续把所有内容堆在父 feature 根目录,会明显增加导航与修改成本
如果只是把文件按技术层重新分组,而没有形成新的业务边界,就不要称之为子 feature。
前端 feature 内部的用户入口、面板、欢迎页、配置页等,如果已经拥有自己的容器、展示部件、测试和业务上下文选择逻辑,应优先作为父 feature 下的子 feature 落到 features/<subfeature>/。父级 components/ 只放 layout、shell、跨子 feature 组合层或非常轻量的展示部件;不要因为某个 UI 当前由父页面渲染,就把独立业务入口长期塞在父级 components/ 下。
什么时候引入 contributions/
contributions/ 是 kernel 内部旁路能力的顶层组织角色,不是 features/ 的别名,也不是通用插件目录。
只有同时满足以下条件时,才允许引入 contributions/:
- 当前 package 本身是 kernel / runtime 级装配作用域,而不是普通业务 feature 或 UI feature。
- 该能力不属于主链路 owner,只是监听已有事实、投影派生状态、写回已有 owner 或补充内部体验。
- kernel 只需要管理该能力的生命周期,不需要理解它的内部业务逻辑。
- contribution 内部不应成为其他模块复用的公共实现来源;外部默认只依赖它的 contribution class。
- kernel contribution 构造器默认直接接收
NextclawKernel这类 kernel owner;不要从外部把toolManager、eventBus、sessionManager等依赖拆碎后传入 contribution。若 contribution 需要的能力还不是 kernel 上的稳定 owner,应先补齐 kernel owner / 访问点,而不是用 options bag 绕过边界。
contributions/<name>/ 是一个独立 contribution root。该 root 默认只暴露 index.ts 作为唯一入口,内部实现按角色进入 utils/、types/、services/ 等子目录。若某个 contribution 需要隔离自己的局部分支能力,可以在内部继续使用 contributions/<child>/index.ts 嵌套 contribution root;嵌套 root 仍遵循唯一入口和内部角色目录规则。不要把 contribution root 当成平铺文件夹,也不要把 contribution 的内部工具通过入口重新导出。
什么时候允许 shared/
shared/ 是可选目录,而且默认应当偏少。
只有同时满足以下条件,文件才允许进入 shared/:
- 被两个及以上 sibling scope 真实复用
- 契约稳定
- 不属于某个 feature 的私有业务逻辑
- 不是因为“暂时不知道放哪”才被丢进去
如果某段代码只服务于一个 feature,就应留在该 feature root 内部。
shared/ 的固定语义
shared/ 不是第二套 feature 根,也不是“暂时不知道放哪”的回收站。
shared/ 只承载稳定共享层,放进去的内容必须满足两个前提:
- 它的共享关系是真实存在的,而不是预判式抽取
- 它表达的是通用职责抽象,而不是具体业务域
因此,shared/ 的一级目录必须优先使用通用职责目录名,而不是业务目录名。
通用职责目录总白名单
通用职责目录不是可以临时发明的,它们应来自一份固定总白名单。
固定总白名单如下:
components/configs/hooks/presenters/stores/managers/services/pages/types/utils/providers/controllers/repositories/routes/
这里要明确:
- 这份总白名单描述的是“职责角色”,不是“当前 scope 必须全部拥有”
- 各作用域只能从这份总白名单里按需选择适用项
shared/lib/不属于这份普通职责白名单,它是特殊目录
shared/ 一级目录规则
在前端 L3 场景下,shared/ 的一级目录应使用:
- 特殊目录:
lib/ - 以及通用职责目录总白名单中的任意适用项
这里要明确:
shared/下允许的是“非 feature 的通用职责类目录”features/不允许出现在shared/下- 业务目录名不允许出现在
shared/下 - 渠道名、平台名、集成面目录也不允许出现在
shared/下
例如以下目录名不应作为 shared/ 一级目录出现:
transport/marketplace/auth/remote-access/desktop/web/
如果某个目录名本身已经在表达业务域、平台域或集成域,就说明它不属于 shared/ 的稳定抽象层。
shared/components、shared/configs、shared/hooks、shared/types
这几类目录采用“文件直放、无 barrel”的规则:
- 允许直接放文件
- 不要求为了单文件额外包一层目录
- 根下禁止新增
index.ts(x) - 对外导入时直接导入文件本身
components/不要求额外追加.componenthooks/不要求额外追加.hookhooks/中的文件应继续使用use-<domain>.ts(x)configs/中的文件应使用*.config.ts(x)types/中的文件应使用*.types.ts
示例:
shared/
├── components/
│ └── button.tsx
├── configs/
│ └── model.config.ts
├── hooks/
│ └── use-copy.ts
└── types/
└── pagination.types.ts
允许:
import { Button } from "@/shared/components/button";
import { modelConfig } from "@/shared/configs/model.config";
import { useCopy } from "@/shared/hooks/use-copy";
import type { Pagination } from "@/shared/types/pagination.types";
禁止:
import { Button } from "@/shared/components";
import { modelConfig } from "@/shared/configs";
import { useCopy } from "@/shared/hooks";
import type { Pagination } from "@/shared/types";
shared/lib
shared/lib/ 是 shared/ 下面的特殊目录,它不是“再放一些 utils”的兜底层,而是“模拟独立包”的强边界模块层。
规则如下:
shared/lib/根下禁止直接放文件shared/lib/根下只允许子目录shared/lib/根下禁止新增index.ts(x)- 每个子目录都视为一个模拟独立包的小模块
- 每个子目录必须有
index.ts(x) - 该
index.ts(x)是该模块唯一公共出口 - 禁止 deep import 到模块内部文件
shared/lib/下的 sibling 模块之间也不得直接引用对方内部文件
推荐示例:
shared/
└── lib/
├── date-format/
│ ├── index.ts
│ └── date-format.utils.ts
└── project-root/
├── index.ts
└── project-root.utils.ts
允许:
import { formatDate } from "@/shared/lib/date-format";
禁止:
import { formatDate } from "@/shared/lib/date-format/date-format.utils";
import { formatDate } from "@/shared/lib/date-format/index";
shared/ 的唯一导入地址原则
shared/ 的目标不是“目录长得整齐”,而是强制稳定的唯一导入地址。
具体来说:
shared/components、shared/hooks、shared/types采用“文件即入口”shared/lib/*采用“目录即入口”- 不应让同一共享模块同时暴露多个平行导入地址
如果一个共享能力同时支持:
- 从目录根导入
- 从目录内文件导入
- 从某个额外 barrel 导入
那么它的边界就是不稳定的,这违反 shared/ 的治理目标。
包内导入路径协议
对已经显式采纳目录结构协议、并声明了 importAliasPrefixes 的模块,包内导入应遵守统一协议:
- 同目录文件之间使用
./ - 只要跨目录,就使用该模块声明的 alias
- 禁止使用
../、../../这类父级相对路径在包内跨目录穿透 - 导入目录型公共入口时,直接导入目录名本身,禁止显式写
index、index.ts(x)
可被源码依赖的 Package Alias 例外
@/ 只适合 app / worker / CLI 应用层这类拥有唯一应用根的模块。可被其他 workspace 包以源码方式依赖的 package、SDK、shared library,禁止使用泛化 @/ 作为内部导入 alias。
原因:
- 这些包常被 consumer 的
tsconfig.paths直接指向src/index.ts。 - 包内部的
@/types/*、@/services/*会被 consumer 自己的@/*抢走解析。 - 在 consumer tsconfig 里补
@/services/*、@/types/*这类窄映射只是掩盖冲突,不是正确结构。
正确选择:
- 小型 SDK / shared library:优先使用相对路径。
- 确实需要 alias 的可复用 package:使用包级唯一 alias,例如
@kernel/*,并在该 package 的module-structure.config.json中声明。 - 不要为了通过治理,把 library 临时套成 app-l1 的
@/规则。
判断一句话:只要这个包可能被别的 workspace 以源码形式消费,就不能用通用 @/;要么相对路径,要么包级唯一 alias。
跨 Package 公共入口原则
一个 workspace package 导入另一个 workspace package 时,只能使用对方 package 根公共入口。
- 允许:
import { RuntimeCommandService } from "@nextclaw-service" - 禁止:
import { RuntimeCommandService } from "@nextclaw-service/shared/services/runtime/runtime-command.service.js"
包内部可以继续使用该包已有的内部 alias 或相对路径;这条规则约束的是跨 workspace 依赖边界。新增或触达跨包导入时,必须确认 pnpm lint:new-code:package-public-imports 或 pnpm lint:new-code:governance 能拦住 deep import 漂移。
允许:
import { localHelper } from "./local-helper";
import { setupApp } from "@/app/setup-app";
import { formatDate } from "@/shared/lib/date-format";
import { createKernel } from "@kernel/app/create-kernel";
禁止:
import { setupApp } from "../app/setup-app";
import { formatDate } from "../../shared/lib/date-format";
import { formatDate } from "@/shared/lib/date-format/index";
import type { ClientOptions } from "@/types/client.types"; // 出现在可复用 SDK/package 内时禁止
白名单规则
总规则
- 每一层作用域都应先使用一份“允许目录白名单”来约束结构。
- 白名单内目录全部都是“按需可选”,不要求补齐。
- 白名单外目录默认禁止,不允许随手新增弱语义目录。
- 如果必须突破白名单,必须同时说明:
- 为什么现有白名单承载不了
- 为什么这不是命名漂移或职责逃逸
- 长期归宿或退出条件是什么
不要把“按需创建”理解成“可以任意发明新目录”。
应用根白名单
应用根或 package 根允许出现以下目录,全部按需可选:
app/:启动、路由、装配、依赖注入、provider wiring、server/bootstrapfeatures/:当前作用域存在多个并列 feature 时使用shared/:当前作用域存在真实共享内容时使用platforms/:仅当前作用域需要前端多平台差异时使用- 其余职责目录只能从固定通用职责目录总白名单中按需选择,不允许临时发明新角色目录
如果当前作用域已经明确采用 L3,则应用根应进一步收敛为固定骨架:
app/features/shared/platforms/
此时默认不再允许把 hooks/、services/、stores/、utils/ 等通用职责目录直接平铺在应用根。
前端 Feature Root 白名单
前端 feature root 允许出现以下目录,全部按需可选:
features/:当前 feature 存在多个稳定子 feature 时使用- 固定通用职责目录总白名单中的任意适用项
shared/:仅在当前 feature 的多个子 feature 之间存在真实共享时使用
前端 feature root 默认不应自行再放 app/。
后端 Feature Root 白名单
后端 feature root 允许出现以下目录,全部按需可选:
features/:当前 feature 存在多个稳定子 feature 时使用- 固定通用职责目录总白名单中的任意适用项
shared/:仅在当前 feature 的多个子 feature 之间存在真实共享时使用
后端 feature root 默认不应出现 components/、hooks/、pages/、stores/ 这类明显前端运行时导向的目录,除非该作用域本身就是前端运行时或同构渲染层。
禁止目录示例
以下目录名默认禁止作为白名单外兜底目录出现:
common/misc/helpers/support/temp/modules/lib/integrations/workers/consumers/
除非它们在当前作用域被明确定义为白名单目录,且职责边界被清楚写明。
命名规则
- 目录名统一使用
kebab-case - feature 与子 feature 名称必须体现业务域,而不是技术层名字
- 通用源码默认使用显式角色后缀:
configs/使用.config.ts(x).manager.ts.service.ts.repository.ts.controller.ts.types.ts.utils.ts
- 前端特有命名:
.store.tscomponents/目录下不要求.component- hook 文件使用
use-<domain>.ts或use-<domain>.tsx hooks/目录下不要求.hook- 页面入口文件使用
<domain>-page.tsx
- 后端常见命名:
.route.ts.provider.ts
index.ts(x)仅用于导出聚合,不承载业务逻辑
若本次工作涉及仓库级命名治理或重命名,请配合使用 file-naming-convention。
边界规则
- 一个作用域应尽量只有一种主组织模型
- 不要在同一业务域里长期混用
feature-first、根目录散落式组织、宽泛 layer-first - 不要新建白名单外的弱语义兜底目录
- 不要把某个 feature 的私有编排逻辑塞进
shared/ - 如果新结构替代旧结构,必须立刻定义旧结构的退役路径
平台、Package 与 Monorepo 规则
平台拆分
只有在前端平台约束确实不同的情况下,才引入 platforms/。
前端常见平台:
desktop/pwa/web/
不要因为页面长得不完全一样,就立刻镜像整个 feature。
推荐做法:
features/统一承载业务域,platforms/只承载平台差异- 每个平台目录根下只放固定通用职责目录总白名单中的目录,例如
providers/、services/、stores/、utils/ - 每个平台目录根必须有
index.ts或index.tsx,作为平台差异层的唯一出口 - 外部禁止 deep import 平台目录内部文件
- 共享业务 feature 逻辑应优先收敛,而不是为每个平台复制一份再慢慢漂移
- 平台特有的适配器、壳层、桥接与平台 API 接入留在对应平台作用域
CLI
CLI 不是 L3。
它不是前端多平台里的一个平台目录,而是一种独立的 app / package 形态。
如果某个 package 是 CLI,推荐使用 command-first variant:
src/
├── app/
├── commands/
│ ├── remote/
│ ├── service/
│ └── update/
└── shared/
这里要明确:
- CLI 入口统一放在
app/ commands/是 CLI 包的主业务聚合层,语义上等价于其它包中的features/- 参数解析、命令装配、命令分发属于
app/ - 跨多个 command 的稳定共享能力进入
shared/ commands/下禁止出现runtime/、support/、compat/这类不代表真实命令 owner 的伪根目录- 如果 CLI 很简单,也可以不建
commands/,直接按L1处理,让当前根目录保持最小结构
Package
package 不是一种单独的结构模型。
它只是一个结构承载单元,内部仍然继续使用 L1、L2 或 L3。
判断规则是:
- 如果 package 只有一个主能力且仍可保持最小结构,就按
L1 - 如果 package 内有多个并列业务域并需要稳定共享层,就按
L2 - 如果 package 本身是前端多平台应用,才按
L3
Package 拆分
只有满足以下任一条件时,才应新建 package:
- 需要独立发布
- 跨 app 复用已经真实存在且稳定
- 运行时边界或所有权边界很强
- 为了隔离依赖,package 成本是值得的
不要把 package 当作 feature root 混乱后的逃生门。
Monorepo
monorepo 不是包内部结构等级,不应被编号为 L3。
它是仓库级组织规范,负责回答的是:
- 仓库里是否存在
apps/、packages/、tooling/这类一等边界 - 哪些能力应该拆成独立 package
- 哪些边界需要独立发布、独立依赖、独立运行时或独立 ownership
典型结构:
apps/
packages/
tooling/
docs/
这里要明确:
monorepo决定的是“仓库怎么分区”L1、L2、L3决定的是“单个 app / package 内部怎么组织”- 即使仓库采用 monorepo,每个 app / package 内部仍然继续独立选择自己的包内结构模型
- 不要把仓库级拆分和包内 feature 分层揉成一套编号体系
决策流程
在决定目录结构前,按顺序回答:
- 当前讨论的是哪一层作用域:仓库、app、feature,还是 subfeature?
- 如果当前是仓库层,讨论的是 monorepo 边界,还是某个 app / package 的内部结构?
- 当前作用域里有几个稳定并列业务域?
- 如果答案是 1,当前目录能否直接作为 feature root?
- 如果答案是 2 个及以上,是否应该引入
features/作为聚合层? - 候选
shared/内容是否真的被多个 sibling scope 复用? - 本次拆分是业务边界拆分,还是只是技术文件重排?
- 当前目录是否仍在白名单内?
- 是否存在一个更小、更简单但仍然可预测的结构?
始终选择“能保持清晰的最小结构”。
输出要求
当使用本 skill 时,输出中必须包含:
- 当前讨论的是仓库级 monorepo 规范,还是包内部结构规范
- 若为包内部结构,当前选择的复杂度等级:
L1、L2或L3 - 当前作用域的推荐目标结构
- 为什么更简单的结构不够,或为什么更大的结构没必要
- 当前作用域使用的是哪一份目录白名单
- 是否存在白名单外目录;若存在,为什么例外成立
- 什么应留在本地 scope,什么才允许进入
shared/ - 适用的命名规则
- 若为重构场景,本次推荐的迁移顺序
反模式
- 明明只有一个主 feature,却仍然强行套一层
features/ - 明明已经有多个并列业务域,却还把它们散落在根目录平铺
- 在没有稳定业务边界时,过早把内容拆成子 feature
- 把临时实现块、局部 UI 分组误升格为子 feature
- 让
shared/、utils/、types/、common/吸纳私有业务逻辑 - 用“按需创建”为理由不断新增白名单外目录
- 把应用根目录
app/下沉到普通 feature root - 多平台项目中无差别镜像所有 feature 目录
- 还没形成稳定边界,就过早拆 package
推荐搭配
- 当需要扫描混乱目录并识别潜在拆分点时,配合使用 file-organization-governance
- 当需要统一命名、角色后缀和重命名策略时,配合使用 file-naming-convention