当 Read Store 变得太大
为什么 Source State 与 View Projection 不应该永远留在同一个 Store 中
Read Store 是一个好主意。
Infrastructure 加载数据并翻译 DTO。
Infrastructure Adapter 封装具体的 httpResource。
Store 通过 withComputed 从命名明确的 Source Signal 派生 ViewModel。
View 负责 Rendering。
private httpResource → Infrastructure-Adapter → sichere Source Signals → Read Store → withComputed → ViewModel → View对于一个小 Use Case,这通常正是合适的切法。
一个 View。
一个 Source。
一个共同 Lifecycle。
一个规模可控的 ViewModel。
不需要更多。
技术性的 Angular Resource 继续留在 Infrastructure。在那里封装 hasValue()、对 value() 的安全访问、Loading、Error 与 Reload。
Store 只认识构建 Projection 所需要的 Signal。
这不是额外仪式。
它会改善代码的阅读方向:
article → ViewModel
isLoading → Page State
reload intent → ArticleResource.reload()阅读 Store 时,我们关心的是 Orchestration。
而不是 httpResource 的使用说明书。
这并不是一个以后必须被“真正架构”替代的幼稚前置版本。
它本身已经是好的架构。
Retrieve Slice 正是从这里出发:Infrastructure 封装技术性的 Resource Lifecycle,对外提供安全 Source Signal,Store 再把它投影成 Presentation 所需的 State。
只要 Data Source、View State 和 Lifecycle 确实属于同一个责任范围,就没有充分理由为了“以后也许会需要”而提前拆成多个 Store。
三个 Store 并不会自动带来更多架构。
有时它们只是三个需要来回查找的地方。

只要 Data Source、View State 与 Lifecycle 属于同一个职责范围,一个 Store 往往就是最合适的架构。
原来的 Store 并没有错
Section titled “原来的 Store 并没有错”应用很少会永远保持第一个 Slice 那么小。
一个 Article Overview 逐渐发展成 Content System。
Overview 之外又出现更多 Views:
OverviewArchiveWorkspacePreviewReader它们都在处理 Article。
于是一个看似很自然的决定出现了:
我们已经有 Article Store 了。新的状态也继续放进去。
先加入当前 Article 的 Selection。
然后加入 Archive 中展开的条目。
再加入 Restore Dialog 的 Target。
接着加入 Active Workspace Context。
然后是 Preview Locale、Reader Index 和一些 Filter。
最后,同一个 Store 里可能同时存在:
- Catalog Resource;
- Workspace Resource;
- Image Resource;
- Overview Selection;
- Archive Expansion;
- Workspace Dialog Target;
- Filter 与 Sort Intent;
- Preview State;
- Reader Index;
- Read Loading;
- 可见 Write State;
- 多个 Page ViewModel。
这个 Store 依然可能正常工作。
Signal 会更新。
View 能显示数据。
Test 也都是绿色。
不会存在某一个 Commit,让架构突然从“正确”变成“错误”。
这个 Store 对最初的 Use Case 是合理的。只是随着应用增长,越来越多职责被逐步塞进了同一条 Ownership 边界。
这是一个重要区别。
如果事后把最初的 Store 本身定义为错误,我们几乎学不到什么。
更值得问的是:
从什么时候开始,这个 Store 不再拥有一个连贯的 Read Slice,而是同时拥有多个 Lifecycle 和语义完全不同的 State?

Store 的问题不在于行数多,而在于同时拥有太多 Lifecycle 与语义。
问题不在代码行数
Section titled “问题不在代码行数”一个 2,000 行的 Store 值得警惕。
但行数不是架构规则。
一个很大的 Store 仍然可能拥有复杂但内聚的 Use Case:它的 State 一起激活、一起丢弃,而且大多数时候也因为同样的原因一起变化。
反过来,一个只有 250 行的 Store,也可能已经包含三个不同 Owner。
例如:
Catalog Reload → betrifft Overview und Archive
Workspace Selection → betrifft nur den Editor
Reader Index → betrifft nur die aktuelle Lesesitzung这些 State 既不共享 Lifecycle,也不共享 Consumer。
它们只是因为技术上都和 Article 有关,所以被放进了同一个 Store。
这并不是一个有力的理由。
共享同一个数据类型,并不意味着共享同一个 Ownership。
即使 UI 都在展示同一个 Entity,也不能据此推出 State 必须归同一个 Owner。Overview 与 Archive 可以读取同一个 Article Entity,却做出完全不同的判断。
因此更好的诊断不是:
Store 太长了。
而是:
Lifecycle、Consumer、语义和变化原因都不同的 State,共享了同一个 Owner。
代码行数是提示。
Ownership 才是理由。
DTO、Entity 与 ViewModel 仍然是三个模型
Section titled “DTO、Entity 与 ViewModel 仍然是三个模型”在移动 Store Boundary 之前,另一条边界必须继续清楚。
HTTP DTO → Infrastructure Mapper → Domain Entity → gekapselte Source Signals → optionaler Source Store → View Read Store → ViewModel → Presentation这种模型分离与 Use Case 大小无关。
即使一个 Store 完全够用,这条规则依然成立。
DTO 描述传输
Section titled “DTO 描述传输”外部 API 完全可以带有技术痕迹。
export type ArticleDto = { readonly article_id: string; readonly slug?: string | null; readonly headline: string | null; readonly teaser_text?: string | null; readonly publication_status?: 'DRAFT' | 'LIVE' | 'ARCHIVED' | null; readonly hero_image?: { readonly url?: string | null; } | null; readonly author?: { readonly user_id?: string | null; readonly display_name?: string | null; } | null; readonly tags?: readonly (string | null)[] | null; readonly published_at?: string | null; readonly archived_at?: string | null; readonly updated_at: string;};Optional Field、null、Backend 命名以及技术性的嵌套结构,在这里都不是架构错误。
DTO 描述外部 Contract。
它只是不能不加控制地变成前端内部模型。
Entity 描述前端的业务模型
Section titled “Entity 描述前端的业务模型”export type ArticleStatus = 'draft' | 'published' | 'archived';
export type ArticleAuthor = { readonly id: string; readonly name: string;};
export type Article = { readonly id: string; readonly slug: string; readonly title: string; readonly summary: string; readonly status: ArticleStatus; readonly heroImageUrl: string | null; readonly author: ArticleAuthor | null; readonly tags: readonly string[]; readonly publishedAt: number | null; readonly archivedAt: number | null; readonly updatedAt: number;};这个 Entity 已经去除了 Transport Detail。
它使用 Frontend SCS 自己的语言。它不针对某个具体 View,可以被 Overview、Archive、Workspace 和 Preview 共同使用。
它并不因此需要一个 mutable OOP Class。
也不需要 ArticleInterface、ArticleImpl 或抽象的 BaseContentEntityManager。
只要 readonly TypeScript Type 能够清楚表达业务模型,就已经足够。
Infrastructure 从外向内翻译
Section titled “Infrastructure 从外向内翻译”export const mapArticleDto = (dto: ArticleDto): Article => { const title = dto.headline?.trim();
if (!title) { throw new Error(`Article ${dto.article_id} has no title.`); }
return { id: dto.article_id, slug: dto.slug?.trim() || dto.article_id, title, summary: dto.teaser_text?.trim() ?? '', status: mapArticleStatus(dto.publication_status), heroImageUrl: dto.hero_image?.url?.trim() || null, author: mapArticleAuthor(dto.author), tags: (dto.tags ?? []).filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0).map((tag) => tag.trim()), publishedAt: mapTimestamp(dto.published_at), archivedAt: mapTimestamp(dto.archived_at), updatedAt: mapRequiredTimestamp(dto.updated_at, dto.article_id), };};经过这个 Mapper,Transport Model 的生命周期就结束了。
前端其他部分只使用 Article。
Anti-Corruption Layer 文章更详细说明了这条边界为什么重要。对于本文后续讨论,最重要的是:
无论之后使用一个 Store 还是五个 Store,DTO 与 Entity 都保持分离。

API 描述传输,Entity 描述前端业务模型,ViewModel 描述具体 Use Case。
ViewModel 不属于某个 Entity
Section titled “ViewModel 不属于某个 Entity”Entity 描述的是:在 Frontend Bounded Context 内,一个 Article 是什么。
ViewModel 描述的是:这个 Article 对某一个具体界面意味着什么。
这不是同一件事。
一个拥有以下信息的 Article:
status: 'archived';在不同 View 中可以被完全不同地解释。
Overview → nicht anzeigen
Archive → Wiederherstellen anbieten
Workspace → Bearbeitung deaktivieren
Preview → keine veröffentlichbare Darstellung erzeugenEntity 没有必要因此增加四个不同的 Status Field。
它只提供业务事实。
各个 View 再从这些事实中投影出自己的含义。
Article ├→ ArticleOverviewItemVm ├→ ArticleArchiveItemVm ├→ ArticleWorkspaceVm └→ ArticlePreviewVm正是在这里,“是否需要独立 View Read Store”这个问题才真正出现。
不是因为每个 View 天生都应该有自己的 Store。
而是因为多个 View 开始越来越不同地解释同一批 Entity。
ViewModel Aggregation 的基础文章解释了为什么 ViewModel 不应该只是 Backend Model 的透传。这里再往前一步:
当同一个 Data Source 服务多个 View 时,谁应该拥有这些不同 Projection?
Source Store 拥有共享 Source Context
Section titled “Source Store 拥有共享 Source Context”Source Store 回答一个范围有限的问题:
哪些业务数据需要被多个 Consumer 共同使用?
但它并不自动拥有具体的 Angular Resource。
这是这里新增、也是最重要的一条边界:
Infrastructure-Adapter → besitzt private HttpResourceRef → kapselt hasValue(), value(), Loading, Error und Reload → stellt sichere Source Signals bereit
Source Store → besitzt gemeinsamen Source-Kontext → besitzt Query-Identität und Invalidierungsregeln → stellt Entities und Read-State für mehrere Consumer bereit技术性的 Resource Lifecycle 继续留在 Infrastructure。
Source Store 拥有的是:这个 Source 在 Frontend SCS 中的业务含义。
Infrastructure Adapter
Section titled “Infrastructure Adapter”Infrastructure 首先提供一个被封装的 Source:
@Injectable({ providedIn: 'root' })export class ArticleCatalogResource { private readonly resource = httpResource<readonly Article[]>( () => ({ url: '/api/content/articles', method: 'GET', }), { parse: mapArticleResponse, }, );
readonly articles = computed<readonly Article[] | null>(() => { if (!this.resource.hasValue()) { return null; }
return this.resource.value(); });
readonly isLoading = this.resource.isLoading;
readonly error = computed(() => this.resource.error() ?? null);
reload(): void { this.resource.reload(); }}这个 Class 完整封装了 Angular 的技术 API。
向外不再存在:
HttpResourceRef<Article[]>而是:
articles: Signal<readonly Article[] | null>isLoading: Signal<boolean>error: Signal<unknown | null>reload(): voidhasValue() 放在这里是正确的。
它保护对 value() 的访问。
这不是业务 Guard,也不是 ViewModel Decision,而是 Resource 的技术 Lifecycle Semantics。
因此这个 Guard 不应该离开 Infrastructure。
Source Store
Section titled “Source Store”如果多个 View 共享同一个 Source、Query 与 Invalidation,那么可以在它之上建立一个 Source Store:
type ArticlePageReadState = 'idle' | 'loading' | 'refreshing' | 'ready' | 'error';
export const ArticleCatalogSourceStore = signalStore( { providedIn: 'root' },
withProps(() => { const catalogResource = inject(ArticleCatalogResource);
return { _catalogResource: catalogResource, articles: catalogResource.articles, isLoading: catalogResource.isLoading, readError: catalogResource.error, }; }),
withComputed(({ articles, isLoading, readError }) => ({ readState: computed<ArticlePageReadState>(() => toArticleReadState({ hasData: articles() !== null, isLoading: isLoading(), error: readError(), }), ), })),
withMethods(({ _catalogResource }) => ({ reload: (): void => { _catalogResource.reload(); }, })),);共享 Lifecycle Mapper 已经基于安全的 Source API 工作:
const toArticleReadState = ({ hasData, isLoading, error }: { readonly hasData: boolean; readonly isLoading: boolean; readonly error: unknown | null }): ArticlePageReadState => { if (!hasData && error) { return 'error'; }
if (!hasData && isLoading) { return 'loading'; }
if (!hasData) { return 'idle'; }
return isLoading ? 'refreshing' : 'ready';};值得注意的是,Adapter 和 Source Store 都没有做什么。
它们不会把 Resource Value 再复制到一个单独的 withState。
不会再维护第二份 articlesLoading。
不会在 Source Reload 时手工清空 Error。
也不会在 httpResource 旁边重新实现第二套技术 State Machine。
private httpResource → sicherer Source-Vertrag → gemeinsamer Read-StateSource Store 只是为这份 Contract 命名并共享它。
它不会再次解释 Angular Resource。
它也不拥有 Archive Expansion、Reader Index 或 Preview Dialog Target。
这些不是共享 Source 的属性。
它们属于具体 View 如何看待这些数据。
对于一个很小、并不存在共享 Source Ownership 的 Use Case,这个额外 Source Store 可能完全没有必要。
这时 View Read Store 可以直接消费被封装的 ArticleCatalogResource。
只有当多个 Consumer 确实共享同一个 Source Context 时,Source Store 才值得存在。
View Read Store 拥有 Projection
Section titled “View Read Store 拥有 Projection”View Read Store 回答的是另一个问题:
这些数据对这个具体 View 意味着什么?
为此,它会读取:
- 来自 Source Store 或直接来自封装 Infrastructure Adapter 的 Domain Entity;
- 已经归一化的 Read State;
- 自己的 View State;
- 可选的一份具体 readonly Write State Projection。
它可能拥有:
- Selection;
- Expansion;
- Filter 与 Sort Intent;
- Active Tab;
- Dialog Target;
- Reader Index;
- Viewport State;
- Use-Case-specific Derivation。
然后通过 withComputed,把这些 Input 投影成完整 Page ViewModel。
真正的 Case Decision 继续放在命名明确、Pure 的 Projection Function 中。
阅读 Store 时,我们希望看到的主要是:
Source+ View-owned State+ optionaler Write-State → Page ViewModel但它并不自动拥有全部 Input State。
这是一个重要区别。
Infrastructure-Adapter → besitzt technische HttpResourceRef → stellt sichere Source Signals bereit
Source Store → besitzt gemeinsamen Source-Kontext → besitzt Query, Invalidierung und geteilten Read-State
View Read Store → besitzt View-State → orchestriert die Projektion
Write-State-Projektion → besitzt sichtbaren Write-LifecycleView Read Store 是 Projection 发生的地方。
不是整个应用的最终仓库。
两个 View,两种 Projection
Section titled “两个 View,两种 Projection”Overview 和 Archive 使用同一批 Article Entity。
但它们的 Mapper 会做出不同判断。
Overview Projection
Section titled “Overview Projection”export type ArticleOverviewItemVm = { readonly id: string; readonly title: string; readonly summary: string; readonly authorName: string | null; readonly tags: readonly string[]; readonly updatedAt: number;};
export const toArticleOverviewItemVm = (article: Article): ArticleOverviewItemVm | null => { if (article.status !== 'published') { return null; }
return { id: article.id, title: article.title, summary: article.summary, authorName: article.author?.name ?? null, tags: article.tags, updatedAt: article.updatedAt, };};一个已归档 Article 不会带着 isVisible: false 继续被传进 Template。
它根本不属于 Overview Projection。
Archive Projection
Section titled “Archive Projection”export type ArticleArchiveItemVm = { readonly id: string; readonly title: string; readonly archivedAt: number | null; readonly canRestore: boolean;};
export const toArticleArchiveItemVm = (article: Article): ArticleArchiveItemVm | null => { if (article.status !== 'archived') { return null; }
return { id: article.id, title: article.title, archivedAt: article.archivedAt, canRestore: true, };};同一个 Entity 在这里拥有另一种 UI 含义。
不是因为 Archive 修改了 Entity。
而是因为它问的是另一个问题。
ViewModel 应该包含语义化 Value。
日期格式、翻译和可见 Label 仍然属于 Presentation。
Domain Layer 不输出德语文本,也不输出 Translation Key。
两个 View,两个 Read Store
Section titled “两个 View,两个 Read Store”Overview Store 拥有 Filter、Sort 与 Selection。
Projection Rule 位于一个 Pure Function 中:
export type ArticleOverviewSort = 'updated' | 'title';
type ArticleOverviewState = { readonly selectedTag: string | null; readonly selectedArticleId: string | null; readonly sortBy: ArticleOverviewSort;};
export type ArticleOverviewPageVm = { readonly readState: 'idle' | 'loading' | 'refreshing' | 'ready' | 'error'; readonly selectedTag: string | null; readonly sortBy: ArticleOverviewSort; readonly items: readonly (ArticleOverviewItemVm & { readonly isSelected: boolean; })[]; readonly isEmpty: boolean;};
const toArticleOverviewPageViewModel = ({ articles, readState, selectedTag, selectedArticleId, sortBy }: { readonly articles: readonly Article[] | null; readonly readState: ArticleOverviewPageVm['readState']; readonly selectedTag: string | null; readonly selectedArticleId: string | null; readonly sortBy: ArticleOverviewSort }): ArticleOverviewPageVm => { const items = (articles ?? []) .filter((article) => selectedTag === null || article.tags.includes(selectedTag)) .flatMap((article) => { const item = toArticleOverviewItemVm(article);
return item ? [ { ...item, isSelected: item.id === selectedArticleId, }, ] : []; }) .toSorted((left, right) => (sortBy === 'title' ? left.title.localeCompare(right.title) : right.updatedAt - left.updatedAt));
return { readState, selectedTag, sortBy, items, isEmpty: articles !== null && items.length === 0, };};Store 只负责连接这些 Input:
export const ArticleOverviewReadStore = signalStore( withState<ArticleOverviewState>({ selectedTag: null, selectedArticleId: null, sortBy: 'updated', }),
withProps(() => ({ _catalog: inject(ArticleCatalogSourceStore), })),
withComputed((store) => ({ vm: computed(() => toArticleOverviewPageViewModel({ articles: store._catalog.articles(), readState: store._catalog.readState(), selectedTag: store.selectedTag(), selectedArticleId: store.selectedArticleId(), sortBy: store.sortBy(), }), ), })),
withMethods((store) => ({ selectTag: (selectedTag: string | null): void => { patchState(store, { selectedTag }); },
selectArticle: (selectedArticleId: string | null): void => { patchState(store, { selectedArticleId }); },
changeSorting: (sortBy: ArticleOverviewSort): void => { patchState(store, { sortBy }); }, })),);Archive 有另一组 State 和另一种 Projection。
type ArticleArchiveState = { readonly expandedArticleIds: readonly string[]; readonly restoreDialogTargetId: string | null;};
export type ArticleArchivePageVm = { readonly readState: 'idle' | 'loading' | 'refreshing' | 'ready' | 'error'; readonly items: readonly (ArticleArchiveItemVm & { readonly isExpanded: boolean; })[]; readonly restoreDialog: { readonly articleId: string; readonly title: string; } | null;};
const toArticleArchivePageViewModel = ({ articles, readState, expandedArticleIds, restoreDialogTargetId }: { readonly articles: readonly Article[] | null; readonly readState: ArticleArchivePageVm['readState']; readonly expandedArticleIds: readonly string[]; readonly restoreDialogTargetId: string | null }): ArticleArchivePageVm => { const items = (articles ?? []).flatMap((article) => { const item = toArticleArchiveItemVm(article);
return item ? [ { ...item, isExpanded: expandedArticleIds.includes(item.id), }, ] : []; });
const dialogTarget = items.find(({ id }) => id === restoreDialogTargetId);
return { readState, items, restoreDialog: dialogTarget ? { articleId: dialogTarget.id, title: dialogTarget.title, } : null, };};Archive Store 同样保持安静:
export const ArticleArchiveReadStore = signalStore( withState<ArticleArchiveState>({ expandedArticleIds: [], restoreDialogTargetId: null, }),
withProps(() => ({ _catalog: inject(ArticleCatalogSourceStore), })),
withComputed((store) => ({ vm: computed(() => toArticleArchivePageViewModel({ articles: store._catalog.articles(), readState: store._catalog.readState(), expandedArticleIds: store.expandedArticleIds(), restoreDialogTargetId: store.restoreDialogTargetId(), }), ), })),
withMethods((store) => ({ toggleExpanded: (articleId: string): void => { const expandedArticleIds = store.expandedArticleIds();
patchState(store, { expandedArticleIds: expandedArticleIds.includes(articleId) ? expandedArticleIds.filter((id) => id !== articleId) : [...expandedArticleIds, articleId], }); },
requestRestore: (articleId: string): void => { patchState(store, { restoreDialogTargetId: articleId, }); },
closeRestoreDialog: (): void => { patchState(store, { restoreDialogTargetId: null, }); }, })),);两个 View Read Store 读取同一个 Source。
但它们不拥有同样的 View State。
也不会做出同样的 Projection Decision。
共享 Read State 只在 Source Store 中,从安全的 Infrastructure Signal 派生一次。
View Read Store 不需要认识 hasValue(),也不需要认识 HttpResourceRef.status()。
因此 Catalog Reload 不会误删 Archive Expansion;Overview Sort 的变化不会使 Reader ViewModel 失效;Preview Dialog 也不会不断扩大一个全局 Article Store 的 Public API。

Source Store 拥有共同 Source Context,View Read Store 拥有各自 Use Case 的意义。
withComputed 是一条边界——直到它变成 Integration Layer
Section titled “withComputed 是一条边界——直到它变成 Integration Layer”withComputed 非常适合连接 ViewModel Projection。
它读取 Signal,并从中派生新的 Signal。
真正的 Projection Rule 仍然应该位于命名明确、Pure 的 Function 中。
Store 本身最好只展示:
Source Signals+ View-owned State+ optionaler Write-State → toWorkspacePageViewModel()所以,一个很长的 computed Block 首先是可读性问题。
但架构上更重要的问题依然是:这里到底汇聚了多少彼此独立的职责?
如果同一个 Store 同时知道这些 Input,就应该警惕:
CatalogWorkspaceImagesIdentityLocaleRoute ContextDialog StateReader StateWrite Pending这时,withComputed 已经不再只是连接某个 Use Case 的 Projection。
它正在变成整个应用内部的 Integration Layer。
每增加一个 View,就多一个 Input。每增加一个 Source,就扩大 Test Setup。某个 Source 的变化甚至可能影响看起来完全独立的 Page ViewModel。
到了这里,值得重新问:
这个 Store 还在编排一条内聚 Projection,还是已经开始协调多个业务上彼此独立的 View?
Multi-Source ViewModel 展示了如何从多个响应式 Source 构建一个 ViewModel。
多个 Source 本身不是问题。
如果 Workspace ViewModel 的语义确实来自 Catalog、当前 Article Context 和 Identity,那么它当然可以读取它们。
真正危险的是,同一个 Store 还同时投影 Overview、Archive、Preview 与 Reader。
一个 Store 对应一个 Resource?
Section titled “一个 Store 对应一个 Resource?”不是。
新的规则并不是:
每个被封装的 Resource 都必须拥有一个 Source Store。
每个具体 httpResource 确实都应该留在一个 Infrastructure Adapter 内部。
但不是每个 Adapter 上面都需要再多套一个 Source Store。
那只会变成另一种 Endpoint Arithmetic。
如果多个 Infrastructure Adapter:
- 服务同一个业务 Context;
- 一起被激活和停用;
- 一起形成一致的 Entity Model;
- 拥有相同 Consumer;
- 一起 Invalidate;
- 在业务变化时通常一起修改;
那么它们完全可以合理地属于同一个 Source Store。
例如,一个 Catalog 可以由 Article Resource 与 Category Resource 共同构成。
如果 Overview 与 Archive 都需要这份共同 Catalog Model,那么 ArticleCatalogSourceStore 可以编排这两套安全 Source API。
反过来,如果 Lifecycle 已经明显分离,那么拆开更合理:
ArticleCatalogSourceStore → langlebiger Catalog → Overview und Archive
ArticleWorkspaceSourceStore → aktiver Article-/Section-Kontext → Workspace und Reader
ArticleImageSourceStore → Blob Cache → Object URLs → eigener Cleanup-Lifecycle三个 Endpoint 可以归一个 Source Store。
一个 Infrastructure Adapter 也可以间接服务多个 View Read Store。
如果根本不存在共享 Source Ownership,一个小 View Read Store 还可以直接消费 Infrastructure Adapter。
HTTP Call 数量不能决定 Store 数量。
更好的问题是:
如果发生一次业务变化,这些 Source 会一起被激活、一起被丢弃、一起被修改吗?
如果答案经常是“不会”,那么所谓共同 Ownership 很可能只是技术上碰巧产生的。
Source Store 也拥有 Query Lifecycle
Section titled “Source Store 也拥有 Query Lifecycle”当多个 View 共享 Source 时,通常还会出现另一个问题:
当前激活的 Query Context 到底归谁所有?
不是 Route。
不是 Component。
也不是多个 View Store 各自保存一份。
Source Store 拥有共同 Source 的业务 Query Identity。
Infrastructure Adapter 再把这个 Query 技术性地转成 httpResource。
Source Store → besitzt articleId, locale und Consumer-Kontext → entscheidet über Aktivierung und Invalidierung
Infrastructure-Adapter → übersetzt diesen Kontext in den HTTP-Request → kapselt den technischen Resource-LifecycleView 负责激活一个具体 Context:
Workspace View → activateWorkspace({ articleId, locale })
Reader View → activateReader({ articleId, locale })多个 Consumer 可以共享同一个 Source Context,但不能各自复制同一份 Query Truth。
因此,一个好的 Source Store 不只知道 Entity 与 Read State。
它还知道:
- die aktive Query-Identität- die aktiven Consumer- die Invalidierungsregeln- die Korrelation eingehender Ergebnisse而 Infrastructure Adapter 知道的是:
- URL und Request-Form- parse und DTO-Mapping- hasValue(), value(), Loading und Error- Reload und technische Abbruchsemantik这种分离可以避免一个尤其令人难受的错误:
Query A startet→ View wechselt zu Query B→ Ergebnis A kommt spät→ Ergebnis A wird als B dargestelltSource Store 不能只分享“某个值”。
它必须知道这个值属于哪个业务 Query。
Infrastructure 则必须确保技术 Request 精确映射这个 Context。
这就是不泄漏 HttpResourceRef 的共享 Source Ownership。
Navigation 不是 Read State
Section titled “Navigation 不是 Read State”View Read Store 可以拥有 Selection、Expansion 与 Dialog Target。
但它不拥有 Router。
业务 Intent 可以这样表达:
Overview→ openArchive(locale)真正的 Navigation 仍然留在 Domain 之外:
UI→ Facade→ Navigation Intent→ Presentation Adapter→ RouterView Read Store 不生成 URL。
Route 可以在进入页面时激活 Context。
但这不会让 Route 变成永久 State Owner。
这样可以把两个方向分开:
Route→ aktiviert einmalig den Domain-Kontext
Domain Intent→ beschreibt eine gewünschte Navigation不需要为此建立永久 Router↔Store 双向同步。
Command 不属于 Read Store
Section titled “Command 不属于 Read Store”到目前为止,我们讨论的全部都是 Read。
但真实应用中的 View 也需要展示正在进行的 Write:
Restore läuftUpload läuftKapitel wird angelegtSeiten werden vorbereitet最自然的捷径是:
Read Store→ liest Command Store正是在这里,职责开始变得模糊。
Angular Resource 对 Read 已经自带技术 Lifecycle:
valuestatusisLoadingerrorInfrastructure Adapter 封装这套 API,并从中提供安全的 Source Signal。
传统 HTTP Command 则不会自动拥有同样 Lifecycle。
如果 Command Store 在执行 HTTP 之外还同时持有 pending、error 与 targetId,它就突然拥有两个职责:
Command-Ausführungunddarstellbarer Write-State这样 View Read Store 就会依赖 Executor 的具体实现方式。
更清晰的切分是:
Command Request → Command Executor → Started → HTTP → Success / Failure OutcomeExecutor 拥有执行。
独立的 Write State Projection 拥有对 UI 可见的 Lifecycle:
Started+ Success / Failure Outcome → Write-State-Projektion → pending targets → UI-relevante FehlerView Read Store 可以 readonly 读取这份 Projection。
但它不认识 Command Executor。
Request 不等于 Started
Section titled “Request 不等于 Started”这个区别听起来很小。
但可以避免错误 State。
Request 描述的是一个 Intent:
restoreArticleRequested它并不能证明 Command 已经真正被接受执行。
使用 exhaustMap 时,如果第一项仍在运行,第二个 Request 可以被忽略。
如果 Write State Projection 对每个 Request 都直接设置 pending = true,那么被忽略的 Request 会产生一个永远等不到 Terminal Outcome 的错误 State。
因此:
Request → Executor nimmt tatsächlich an → Started → HTTP → Success oder Failure一个被忽略的 Request 会产生:
kein Startedkein Pendingkein später fehlendes CompletionWrite State Projection 不响应“想做什么”。
它只响应真正已经开始执行的 Operation。
Write State 是 Projection,不是第二个 Command Store
Section titled “Write State 是 Projection,不是第二个 Command Store”一个业务范围明确的 Write State 可以像这样:
type ArticleCatalogWriteState = { readonly restoringArticleIds: readonly string[];};
export type ArticleCatalogWriteView = { readonly restoringArticleIds: readonly string[]; readonly catalogPending: boolean;};具体 Transition 是:
restoreArticleStarted(articleId) → articleId hinzufügen
restoreArticleSucceeded(articleId) → articleId entfernen
restoreArticleFailed(articleId) → articleId entfernen而不是:
requested → pending = true也不是:
lastResultgeneric Operation RegistryMap<OperationType, OperationState>Projection 只持有 View 真正需要展示的 State。
Upload 可能需要具体的 uploadingPageId。
Restore 可能需要一组当前 Active Article ID。
而一个全局 isBusy 几乎帮不了任何具体 View。
完整的 Page ViewModel
Section titled “完整的 Page ViewModel”这样就得到一个更接近真实项目的切分:
Source Store → Entities → gemeinsamer Read-State
View-owned State → Selection → Expansion → Dialog Target
Write-State-Projektion → konkrete Pending Targets → sichtbare Write Errors这三个 Input 在 View Read Store 中被投影:
Source Store ─────────────────┐View-owned State ─────────────┼→ View Read Store → Page VM → ViewWrite-State-Projektion ───────┘更重要的是,哪些东西没有被连接起来:
View Read Store ✕ Command ExecutorRead Store 不执行 Command。
不 Dispatch Command Request。
不复制 Command Lifecycle。
只有当具体 View 需要展示某个 Write State 时,它才 readonly 读取那份 Projection。
readState → gemeinsam normalisierter Read-Lifecycle
catalogPending → sichtbarer Write-Lifecycle
isRestorePending → use-case-spezifische VM-Ableitung
View Read Store 投影 Source State、View State 和可选 Write State,但不消费 Command Executor。
Outcome 保持 Ephemeral
Section titled “Outcome 保持 Ephemeral”一个 Terminal Command Outcome 可以拥有多个 Consumer:
Success / Failure Outcome ├→ Write-State-Projektion beendet Pending ├→ Source Store stößt passende Source-Invalidierung an ├→ Presentation schließt lokalen Dialog └→ Presentation zeigt Notification这并不是把 Outcome 永久保存进 Read Store 的理由。
不要:
lastResultlastSuccesslastErrorcompletionCounterOutcome 是 Event。
ViewModel 是 State。
Presentation 可以消费一个具体 Outcome,用于本地 Completion:
Create Chapter succeeded→ passenden Dialog schließen但它不能据此再编排新的 Domain Workflow。
Source Store 可以对一个已关联的 Outcome 作出反应:
Article A restored→ passende Catalog-Source invalidieren→ ArticleCatalogResource.reload()但它不能因此随便 Invalidate 当前恰好 Active 的任何 Query。
Correlation 属于 Source Ownership。
具体 Reload Mechanic 继续留在 Infrastructure Adapter。
哪些信号说明边界已经不再合适
Section titled “哪些信号说明边界已经不再合适”没有一个单独的数字可以告诉你“现在必须拆 Store”。
但有一些反复出现的信号。
多个 View 对同一批 Entity 有不同解释
Section titled “多个 View 对同一批 Entity 有不同解释”Overview 隐藏已归档 Article。
Archive 提供 Restore。
Workspace 禁止某些编辑。
Preview 阻止发布。
Data Source 是共同的。
意义不是。
Source 与 View 的生命周期不同
Section titled “Source 与 View 的生命周期不同”Catalog 在 Navigation 期间持续存在。
Reader Index 只在一个打开的阅读 Session 中有效。
Archive Expansion 应该跨 Reload 保留,但离开 Page 时消失。
这些 State 拥有不同 Lifecycle。
Store 拥有彼此独立的 Consumer
Section titled “Store 拥有彼此独立的 Consumer”Overview、Archive 与 Preview 使用同一 Catalog,但它们独立 Rendering,也独立演进。
为了某一个 Consumer 的变化,其他 Consumer 的 Test Setup 却经常被迫扩大。
View State 在 Reload 之后仍然有意义
Section titled “View State 在 Reload 之后仍然有意义”Resource Reload 不应该自动清空 Filter、Sort、Expansion 或 Selection。
那么这些 View State 就不是 Resource Lifecycle 的一部分。
很小的修改产生很大的影响半径
Section titled “很小的修改产生很大的影响半径”为了一个新的 Archive Action,Overview、Workspace 与 Preview 的 Test 都必须调整,只因为它们实例化同一个 Store。
Store 人为地让本来独立的变化互相依赖。
Query 与 Invalidation Rule 开始分离
Section titled “Query 与 Invalidation Rule 开始分离”Catalog 依赖 Locale 与发布范围。
Workspace 依赖 Article ID、Section ID 与编辑 Context。
把两者硬塞进一套共同 Query,会形成业务上毫无关系的组合。
Source State 被复制
Section titled “Source State 被复制”Infrastructure Adapter 已经为 Data、Loading 与 Error 提供安全 Signal。
Store 却又额外保存:
articles: Article[];articlesLoading: boolean;articlesLoaded: boolean;articlesError: string | null;这样就出现了同一个 Read Lifecycle 的两份 Truth。
封装 httpResource 并不能合理化 Store 中第二套 State Machine。
withComputed 已经认识半个应用
Section titled “withComputed 已经认识半个应用”如果想理解一个 Page ViewModel,必须先把 Catalog、Image、Identity、Route、Locale、Dialog 以及多个 Pending State 全部重建在脑中,那么这条 Projection 很可能已经过宽。
这些信号中的任何一个,都不能单独证明必须拆分。
但当它们同时出现时,通常说明原来的 Owner 已经积累了多个变化原因。
什么时候拆分只是在制造 Overhead
Section titled “什么时候拆分只是在制造 Overhead”反面的情况同样重要。
如果满足这些条件,一个 Store 完全可以继续保留:
- 只有一个 View;
- Source 与 View 总是一起存在;
- 没有其他 Consumer 需要这些 Entity;
- View State 很小;
- Query 与 Invalidation 很明确;
withComputed表达的是一条内聚 Projection;- 拆分之后大部分代码只会变成透传。
一个同时消费封装 Infrastructure Source、Selection 与 ViewModel 的 View Read Store,并不自动意味着职责混合。
如果 Selection 只属于这个 View,而且 Source 没有其他 Consumer,两者完全可能拥有同一个合理 Lifecycle。
此时再加一个 Source Store,只会让 View Read Store 从另一份文件重新拼装同样的安全 Signal。
这并没有产生新的边界。
只是把简单性分散开了。
架构不应该提前解决一个尚未出现的预期问题。
小 Use Case 完全可以保持小。
什么让这种拆分真正可测试
Section titled “什么让这种拆分真正可测试”一套架构不能只因为箭头看起来合理就算好。
边界必须可以验证。
Infrastructure Adapter
Section titled “Infrastructure Adapter”测试:
- DTO→Entity-Mapping- technischer hasValue()/value()-Guard- null gegenüber erfolgreich geladenem []- Loading und Error als sichere Signals- Reload delegiert an die private Resource- keine HttpResourceRef verlässt die InfrastructureSource Store
Section titled “Source Store”测试:
- Query-Aktivierung- gemeinsamer Read-State- Ergebnis-Korrelation- Reload und Invalidierung- Deaktivierung ohne Consumer- kein kopierter Resource-StateView Read Store
Section titled “View Read Store”测试:
- owned View-State- Source+View-State→ViewModel-Projektion- Selection- Expansion- Dialog Targets- Read Loading und Write Pending getrennt- keine technische Resource-Semantik im StoreCommand Executor
Section titled “Command Executor”测试:
- Request wird angenommen oder verworfen- Started entsteht erst bei tatsächlicher Annahme- genau ein Success- oder Failure-Outcome- kein UI-State im ExecutorWrite State Projection
Section titled “Write State Projection”测试:
- Started aktiviert den passenden Target-State- Success und Failure beenden nur das passende Target- Mismatches verändern keinen fremden State- parallele Operationen bleiben getrenntComposition Root
Section titled “Composition Root”测试这条架构 Invariant:
Write-State-Projektionen sind aktiv,bevor ein Executor synchron Started emittiert.尤其是 Event-based Projection,看起来完全正确,却仍然可能依赖一个没有被测试的 Bootstrap 顺序。
没有保障的架构,仍然只是一种主张。
一种可能的结构是:
article/├── entities/│ └── article.ts│├── infrastructure/│ ├── article.dto.ts│ ├── article.mapper.ts│ ├── article-catalog.resource.ts│ └── article.commands.ts│├── source-state/│ ├── article-catalog-source.store.ts│ └── article-workspace-source.store.ts│├── overview/│ ├── article-overview.vm.ts│ ├── article-overview.mapper.ts│ └── article-overview-read.store.ts│├── archive/│ ├── article-archive.vm.ts│ ├── article-archive.mapper.ts│ └── article-archive-read.store.ts│├── command/│ └── article-catalog-command.store.ts│└── write-state/ └── article-catalog-write-state.store.ts文件夹名称不是规则本身。
真正关键的是依赖方向:
DTO → Entity → gekapselte Source Signals → optionaler Source State → View Projection → Page ViewModel → Presentation对于 Write:
Intent → Request → Command Executor → Started / Outcome → Write-State-ProjektionInfrastructure Adapter 不认识 ViewModel。
Source Store 不认识 Overview Component。
Overview Store 不认识 API Payload,也不认识 HttpResourceRef。
Archive Mapper 不决定 HTTP Invalidation。
Command Executor 不拥有 UI State。
View Read Store 不认识 Command Executor。
Presentation 不解释 Resource。
每一条边界都回答不同问题。
模型边界必须保持稳定
Section titled “模型边界必须保持稳定”Store Boundary 可以随着应用增长而变化。
Model Boundary 不应该随意变化。
即使一个很小的 Slice,也仍然应该是:
DTO → Entity → ViewModel最初 Entity 与 ViewModel 也许位于同一个 Read Store 中。
没问题。
之后 Source State 与 View Projection 也许被拆开。
同样没问题。
但不应该发生的是:
DTO → überall或者:
Entity → Template entscheidet die Bedeutung或者:
HttpResourceRef → leakt in den Store → Store rekonstruiert technischen Lifecycle或者:
Read Store → Command Executor所以,更合适的记忆方式是:
模型边界是硬约束。Store Boundary 跟随真实的 Ownership 与扩展需求。
不是每个 Infrastructure Adapter 都需要 Source Store。
不是每个 View 都需要自己的 View Read Store。
也不是每个增长中的文件都应该立刻拆分。
但如果不同 Lifecycle、Consumer 与语义已经共享同一个 Owner,就没有必要等到下一次突破一千行才承认边界出了问题。
Read Store 并不会因为应用增长就自动变错。
只是某个时刻,它原来的边界可能不再适合增长后的应用。
这时目标不是把 Store 尽可能切小。
而是重新让职责变得可见。
Infrastructure Adapter 拥有具体 HttpResourceRef 及其技术 Lifecycle。
Source Store 拥有共享业务 Source Context、Query、Invalidation 与共享 Entity。
View Read Store 拥有某个具体 Use Case 的 State 与 Projection。
Command Executor 拥有一次修改的执行。
Write State Projection 拥有某次真正被接受执行的修改所对应的可见 Lifecycle。
Page ViewModel 可以把 Source State、View State 与 Write State 组合起来。
但组合不等于 Ownership。
更不等于:
Read Store → Command Executor也不等于:
View Read Store → HttpResourceRef一个很大的 Store 可以依然内聚。
一个很小的 Store 也可能已经包含多个错误 Owner。
因此真正的问题不是:
这个 Store 有多少行?
而是:
如果发生一次业务变化,哪些 State 真正会一起被修改、激活和丢弃?
如果这个问题已经没有共同答案,那么 Store 很可能并不是“太大”。
而是它的 Ownership 太宽了。