When the Read Store gets too big
Why source state and view projections shouldn’t always stay in the same store
A Read Store is a good idea.
The infrastructure loads data and translates the DTO.
An infrastructure adapter encapsulates the concrete httpResource.
The store uses withComputed to derive a ViewModel from named source signals.
The view renders.
private httpResource → infrastructure adapter → safe source signals → Read Store → withComputed → ViewModel → ViewFor a small use case, that is often exactly the right design.
One view. One source. One shared lifecycle. One manageable ViewModel.
Nothing more is needed.
The technical Angular resource stays in the infrastructure. hasValue(), the safe access to value(), loading, error, and reload get encapsulated there.
The store only knows the signals it needs for its projection.
That’s not extra ceremony. It improves the read flow:
article → ViewModel
isLoading → page state
reload intent → ArticleResource.reload()In the store, you’re interested in the orchestration.
Not the usage instructions of httpResource.
This is not the naive precursor to a later “real” architecture.
This already is good architecture.
The Retrieve Slice shows exactly this starting point: the infrastructure encapsulates the technical resource lifecycle, provides safe source signals, and the store projects the state for presentation from it.
As long as the data source, view state, and lifecycle belong together, there’s no good reason to preemptively split them into several stores.
Three stores aren’t automatically more architecture.
Sometimes they are merely three places you have to inspect.

As long as the data source, view state, and lifecycle belong together, one store is often the best architecture.
The store wasn’t wrong
Section titled “The store wasn’t wrong”The application rarely stays as small as its first slice.
The article overview turns into a content system.
Alongside the overview, more views emerge:
OverviewArchiveWorkspacePreviewReaderAll of them work with articles.
That makes a seemingly simple decision look obvious:
We already have an Article Store. The new states go in there too.
First comes the selection of the active article.
Then the expanded entries in the archive.
Then a dialog target for restoring.
Then the active workspace context.
Then a preview locale, a reader index, and a few filters.
At some point, the same store contains:
- catalog resource
- workspace resource
- image resource
- overview selection
- archive expansion
- workspace dialog targets
- filter and sort intents
- preview state
- reader index
- read loading
- visible write state
- several page ViewModels
The store can keep working through all of this.
The signals update. The views show data. The tests are green.
There’s no single commit where the architecture suddenly tips over.
The store was right for its original use case. The application just gradually put more and more responsibility into the same ownership boundary.
That’s an important distinction.
Whoever declares the original store a mistake in hindsight learns little from it. The more interesting question is:
At what point does this store no longer own a cohesive read slice, but several states with different lifecycles and meanings?

The store doesn’t become problematic through many lines, but through many lifecycles and meanings.
The problem isn’t the line count
Section titled “The problem isn’t the line count”A store with 2,000 lines is suspicious.
But the line count isn’t an architecture rule.
A large store can own a complex, but coherent, use case. Its states activate together, are discarded together, and usually change together.
A store with 250 lines, on the other hand, can already contain three different owners.
For example:
Catalog Reload → affects Overview and Archive
Workspace Selection → affects only the Editor
Reader Index → affects only the current reading sessionThese states share neither the same lifecycle nor the same consumers.
They only sit in the same store because they’re technically related to Article.
That’s a weak justification.
Sharing a data type doesn’t automatically mean sharing ownership.
A shared representation doesn’t justify a shared state owner either. Overview and Archive can read the same Article entity and still make completely different decisions.
That’s why the better diagnosis isn’t:
The store is too long.
But:
States with different lifecycles, consumers, meanings, and reasons for change share the same owner.
The line count is a hint.
Ownership is the justification.
DTO, entity, and ViewModel stay three models
Section titled “DTO, entity, and ViewModel stay three models”Before we shift store boundaries, another boundary has to stay clear.
HTTP DTO → infrastructure mapper → domain entity → encapsulated source signals → optional source store → View Read Store → ViewModel → presentationThis model separation doesn’t depend on the size of the use case.
It applies even when a single store is completely sufficient.
The DTO describes the transport
Section titled “The DTO describes the transport”An external API is allowed to be technically shaped.
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 fields, null, backend naming, and technical nesting aren’t an architecture mistake here.
The DTO describes an external contract.
It just mustn’t turn into the frontend’s internal model unchecked.
The entity describes the frontend domain model
Section titled “The entity describes the frontend domain model”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;};This entity has been cleaned of transport concerns.
It uses the language of the frontend SCS. It is not tailored to one specific view, and it can be used together by Overview, Archive, Workspace, and Preview.
It doesn’t need a mutable OOP class for that.
It doesn’t need an ArticleInterface, an ArticleImpl, or an abstract BaseContentEntityManager either.
A readonly TypeScript type is enough, as long as it cleanly expresses the domain model.
The infrastructure translates from outside to inside
Section titled “The infrastructure translates from outside to inside”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), };};After this mapper, the transport model is done.
The rest of the frontend works with Article.
The Anti-Corruption Layer article covers in more depth why this boundary matters. For the rest of this discussion, what matters most is:
The DTO and the entity stay separate, whether we later use one store or five.

The API describes the transport. The entity describes the frontend domain model. The ViewModel describes the concrete use case.
The ViewModel doesn’t belong to an entity
Section titled “The ViewModel doesn’t belong to an entity”An entity describes what an article is within the frontend’s bounded context.
A ViewModel describes what this article means for a specific view.
That’s not the same thing.
An article with:
status: 'archived';can get interpreted differently across several views.
Overview → don't display
Archive → offer restore
Workspace → disable editing
Preview → don't produce a publishable renderingThe entity doesn’t need four different status fields for that.
It provides the domain information.
The views project their meaning from it.
Article ├→ ArticleOverviewItemVm ├→ ArticleArchiveItemVm ├→ ArticleWorkspaceVm └→ ArticlePreviewVmThis is exactly where the question of separate View Read Stores begins.
Not because every view necessarily deserves a store.
But because several views increasingly interpret the same entities differently.
The foundational article on ViewModel Aggregation explains why a ViewModel isn’t a passed-through backend model. Here, it’s about the next step:
Who owns the different projections when the same data source serves multiple views?
The Source Store owns the shared source context
Section titled “The Source Store owns the shared source context”The Source Store answers a bounded question:
Which domain data is jointly available to several consumers?
It doesn’t automatically own the concrete Angular resource, though.
That’s the new, important boundary:
Infrastructure adapter → owns the private HttpResourceRef → encapsulates hasValue(), value(), loading, error, and reload → provides safe source signals
Source Store → owns the shared source context → owns the query identity and invalidation rules → provides entities and read state to several consumersThe technical resource lifecycle stays in the infrastructure.
The Source Store owns the shared source context within the frontend SCS.
Infrastructure adapter
Section titled “Infrastructure adapter”The infrastructure first provides an encapsulated 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(); }}This class fully encapsulates the technical Angular API.
The adapter no longer exposes:
HttpResourceRef<Article[]>but:
articles: Signal<readonly Article[] | null>isLoading: Signal<boolean>error: Signal<unknown | null>reload(): voidhasValue() is correct here.
It protects access to value().
That’s not a domain guard and not a ViewModel decision. It’s technical lifecycle semantics of the resource.
That’s exactly why this guard doesn’t leave the infrastructure.
Source Store
Section titled “Source Store”When several views share the same source, query, and invalidation, a Source Store can sit above it:
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(); }, })),);The shared lifecycle mapper already works on the safe 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';};Notice what neither the adapter nor the Source Store do.
They don’t copy the resource value into their own withState.
They don’t hold a second articlesLoading.
They don’t manually reset an error when the source reloads.
They don’t build a second technical state machine next to httpResource.
private httpResource → safe source contract → shared read stateThe Source Store names and shares this contract.
It doesn’t interpret the Angular resource all over again.
It also doesn’t own an archive expansion, a reader index, or a preview dialog target.
Those aren’t properties of the shared source.
They’re properties of how a concrete view looks at this data.
For a small use case without shared source ownership, this additional Source Store might be unnecessary.
Then the View Read Store can consume the encapsulated ArticleCatalogResource directly.
The Source Store only earns its existence once several consumers actually share a common source context.
The View Read Store owns the projection
Section titled “The View Read Store owns the projection”The View Read Store answers a different question:
What does this data mean for exactly this view?
For that, it reads:
- domain entities from a Source Store or directly from an encapsulated infrastructure adapter
- an already normalized read state
- its own view state
- optionally a concrete readonly write-state projection
It owns, for example:
- selection
- expansion
- filter and sort intents
- active tabs
- dialog targets
- reader index
- viewport state
- use-case-specific derivations
From these inputs, it produces a complete page ViewModel with withComputed.
The fallback decisions themselves stay in named, pure projection functions.
When you read the store, it should only show:
Source+ view-owned state+ optional write state → page ViewModelIt doesn’t automatically own all input states, though.
That’s an important distinction.
Infrastructure adapter → owns the technical HttpResourceRef → provides safe source signals
Source Store → owns the shared source context → owns query, invalidation, and shared read state
View Read Store → owns view state → orchestrates the projection
Write-State Projection → owns the visible write lifecycleThe View Read Store is the projection boundary.
It’s not the final resting place of the entire application.
Two views, two projections
Section titled “Two views, two projections”Overview and Archive use the same Article entities.
Their mappers make different decisions, though.
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, };};An archived article doesn’t get passed to the template with isVisible: false.
It doesn’t belong in the overview projection at all.
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, };};The same entity now gets a different UI meaning.
Not because the archive changes the entity.
But because it asks a different question.
The ViewModels contain semantic values here.
Date formatting, translations, and visible labels stay in presentation.
The domain layer delivers no localized text and no translation keys.
Two views, two Read Stores
Section titled “Two views, two Read Stores”The Overview Store owns filtering, sorting, and selection.
The projection rule lives in a 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, };};The store now only orchestrates the inputs:
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 }); }, })),);The Archive owns different states and a different 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, };};The Archive Store stays just as quiet:
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, }); }, })),);Both View Read Stores read the same source.
But they don’t own the same view state.
And they don’t make the same projection decisions.
The shared read state gets derived once, in the Source Store, from the safe infrastructure signals.
The View Read Stores don’t have to know hasValue() or HttpResourceRef.status().
A catalog reload therefore doesn’t accidentally wipe the archive expansion. A changed overview sort doesn’t invalidate the reader ViewModel. A preview dialog doesn’t extend the public API of a global Article Store.

The Source Store owns the shared source context. The View Read Stores own the meaning for their respective use case.
withComputed is a boundary — until it becomes an integration layer
Section titled “withComputed is a boundary — until it becomes an integration layer”withComputed is a good place to wire up ViewModel projections.
It reads signals and derives new signals from them.
The projection rules themselves should live in named, pure functions in the process.
When you read the store, it should ideally only show:
Source Signals+ view-owned state+ optional write state → toWorkspacePageViewModel()A long computed block is, at first, a readability problem.
But the architecturally more important question remains: how many independent responsibilities converge there?
It becomes critical once the same store simultaneously knows these inputs:
CatalogWorkspaceImagesIdentityLocaleRoute ContextDialog StateReader StateWrite PendingThen withComputed no longer wires up just the projection of one use case.
It becomes the internal integration layer of the entire application.
Every new view adds one more input. Every new source expands the test setup. Every change to a source can affect seemingly independent page ViewModels.
At the latest by then, the question is worth asking:
Is this store still orchestrating one cohesive projection — or is it already coordinating several independent views?
The Multi-Source ViewModel article shows how a ViewModel can emerge from several reactive sources.
Multiple sources aren’t the problem here.
A workspace ViewModel is allowed to read the catalog, the active article context, and identity, if these are exactly the inputs that jointly make up the workspace view.
It only becomes critical once the same store also projects Overview, Archive, Preview, and Reader.
One resource per store?
Section titled “One resource per store?”No.
The new rule isn’t:
Every encapsulated resource gets a Source Store.
Every concrete httpResource should stay encapsulated inside an infrastructure adapter.
But not every adapter needs its own Source Store on top of that.
That would just be a new form of endpoint arithmetic.
Several infrastructure adapters can reasonably belong to one Source Store if they:
- serve the same domain context
- get activated and deactivated together
- together form a consistent entity model
- have the same consumers
- get invalidated together
- usually change together on domain changes
A catalog, for example, can emerge from an Article resource and a Category resource.
If Overview and Archive both need this shared catalog model, an ArticleCatalogSourceStore can orchestrate both safe source APIs.
A separation, on the other hand, is plausible when the lifecycles diverge:
ArticleCatalogSourceStore → long-lived catalog → Overview and Archive
ArticleWorkspaceSourceStore → active article/section context → Workspace and Reader
ArticleImageSourceStore → blob cache → object URLs → its own cleanup lifecycleThree endpoints can end up as one Source Store.
One infrastructure adapter can indirectly serve several View Read Stores.
And a small View Read Store can directly consume an infrastructure adapter, when no shared source ownership exists.
The number of HTTP calls doesn’t decide the number of stores.
The better question is:
Would these sources activate together, be discarded together, and change together for a domain change?
If the answer is regularly “no,” the shared ownership probably only came about technically.
The Source Store also owns the query lifecycle
Section titled “The Source Store also owns the query lifecycle”With several views, another question often comes up:
Who owns the active query context?
Not the route.
Not the component.
Not several view stores at once.
The Source Store owns the identity of the shared domain query.
The infrastructure adapter technically translates this query into an httpResource.
Source Store → owns articleId, locale, and consumer context → decides on activation and invalidation
Infrastructure adapter → translates this context into the HTTP request → encapsulates the technical resource lifecycleViews activate a concrete context:
Workspace View → activateWorkspace({ articleId, locale })
Reader View → activateReader({ articleId, locale })Several consumers are allowed to use the same source context. But they must not independently copy the same query state as a source of truth.
A good Source Store, then, doesn’t just know entities and read state.
It also knows:
- the active query identity- the active consumers- the invalidation rules- the correlation of incoming resultsThe infrastructure adapter, on the other hand, knows:
- URL and request shape- parse and DTO mapping- hasValue(), value(), loading, and error- reload and technical abort semanticsThis separation prevents a particularly unpleasant bug:
Query A starts→ view switches to Query B→ Result A arrives late→ Result A gets displayed as BThe Source Store must not just share any value.
It has to know which domain query this value belongs to.
The infrastructure has to make sure the technical request maps exactly to this context.
That’s shared source ownership without HttpResourceRef leakage.
Navigation is not read state
Section titled “Navigation is not read state”A View Read Store owns selection, expansion, and dialog targets.
It doesn’t own the router.
A domain intent is allowed to look like this:
Overview→ openArchive(locale)The router navigation stays outside the domain:
UI→ Facade→ Navigation Intent→ Presentation Adapter→ RouterThe View Read Store doesn’t produce a URL.
The route is allowed to activate a context on entry.
It doesn’t become the permanent state owner because of that, though.
That keeps two directions separate:
Route→ activates the domain context once
Domain Intent→ describes a desired navigationA permanent router-store synchronization isn’t necessary for this.
Commands don’t belong in the Read Store
Section titled “Commands don’t belong in the Read Store”Up to here, this has been exclusively about reads.
In real applications, though, a view also shows ongoing changes:
Restore in progressUpload in progressChapter being createdPages being preparedThe obvious shortcut reads:
Read Store→ reads Command StoreThat’s exactly where it gets messy.
Angular resources already have a technical lifecycle for reads:
valuestatusisLoadingerrorThe infrastructure adapter encapsulates this API and provides safe source signals from it.
A classic HTTP command doesn’t automatically have this lifecycle.
If a Command Store also holds pending, error, and targetId alongside HTTP execution, it suddenly has two responsibilities:
Command executionanddisplayable write stateThe View Read Store then becomes coupled to the executor’s implementation.
The cleaner separation reads:
Command Request → Command Executor → Started → HTTP → Success / Failure OutcomeThe executor owns the execution.
A separate write-state projection owns the visible lifecycle:
Started+ Success / Failure Outcome → Write-State Projection → pending targets → UI-relevant errorsThe View Read Store is allowed to read this projection readonly.
It doesn’t know a Command Executor, though.
Request is not Started
Section titled “Request is not Started”This distinction sounds small.
But it prevents incorrect state.
A request describes an intention:
restoreArticleRequestedIt doesn’t yet prove that the command actually got accepted.
With exhaustMap, a second request can get discarded while the first one is still running.
If the write-state projection reacted to every request with pending = true, a state would arise for which no terminal outcome ever comes.
That’s why:
Request → executor actually accepts it → Started → HTTP → Success or FailureAn ignored request produces:
no Startedno Pendingno later missing completionThe write-state projection doesn’t react to intentions.
It reacts to operations that actually got accepted.
Write state is a projection, not a second Command Store
Section titled “Write state is a projection, not a second Command Store”A narrowly scoped write state can look like this, for example:
type ArticleCatalogWriteState = { readonly restoringArticleIds: readonly string[];};
export type ArticleCatalogWriteView = { readonly restoringArticleIds: readonly string[]; readonly catalogPending: boolean;};The transitions are explicit:
restoreArticleStarted(articleId) → add articleId
restoreArticleSucceeded(articleId) → remove articleId
restoreArticleFailed(articleId) → remove articleIdNot:
requested → pending = trueAnd also not:
lastResultgeneric Operation RegistryMap<OperationType, OperationState>The projection only holds the state a view actually has to display.
An upload might need a concrete uploadingPageId.
A restore might need a set of active article IDs.
A global isBusy, on the other hand, helps almost nobody.
The complete page ViewModel
Section titled “The complete page ViewModel”That gives us a more realistic design:
Source Store → entities → shared read state
View-owned State → selection → expansion → dialog target
Write-State Projection → concrete pending targets → visible write errorsThese three inputs get projected in the View Read Store:
Source Store ─────────────────┐View-owned State ─────────────┼→ View Read Store → Page VM → ViewWrite-State Projection ───────┘What matters is what’s not connected:
View Read Store ✕ Command ExecutorThe Read Store doesn’t execute commands.
It doesn’t dispatch command requests.
It doesn’t copy a command lifecycle.
It only reads a readonly projection, when its view actually has to display that state.
readState → jointly normalized read lifecycle
catalogPending → visible write lifecycle
isRestorePending → use-case-specific VM derivation
The View Read Store projects source, view, and optional write state. It doesn’t consume a Command Executor.
Outcomes stay ephemeral
Section titled “Outcomes stay ephemeral”A terminal command result can have several consumers:
Success / Failure Outcome ├→ Write-State Projection ends pending ├→ Source Store triggers matching source invalidation ├→ Presentation closes a local dialog └→ Presentation shows a notificationThat’s no reason to store the outcome permanently in the Read Store.
None of:
lastResultlastSuccesslastErrorcompletionCounterAn outcome is an event.
A ViewModel is state.
The presentation layer may consume a concrete outcome for a local completion:
Create Chapter succeeded→ close the matching dialogIt’s not allowed to orchestrate a new domain workflow from it, though.
The Source Store is allowed to react to a correlated outcome:
Article A restored→ invalidate the matching catalog source→ ArticleCatalogResource.reload()It’s not allowed to invalidate just any query that happens to be active, though.
The correlation belongs to source ownership.
The concrete reload mechanics stay in the infrastructure adapter.
Signs that the boundary no longer fits
Section titled “Signs that the boundary no longer fits”There’s no single metric at which a store must be split.
But there are recurring signs.
Several views interpret the same entities differently
Section titled “Several views interpret the same entities differently”Overview hides archived articles.
Archive offers restore.
Workspace locks editing.
Preview prevents publishing.
The data source is shared. The meaning isn’t.
Source and view live for different lengths of time
Section titled “Source and view live for different lengths of time”The catalog stays loaded during navigation.
The reader index only applies to one open reading session.
The archive expansion should survive a reload, but disappear when leaving the page.
These states have different lifecycles.
The store has independent consumers
Section titled “The store has independent consumers”Overview, Archive, and Preview use the same catalog, but get rendered and developed independently.
A change for one consumer regularly expands the test setup of the others.
View state stays relevant despite a reload
Section titled “View state stays relevant despite a reload”A reload of the resource shouldn’t automatically reset filter, sorting, expansion, or selection.
Then the view state isn’t part of the resource lifecycle.
Small changes create a large blast radius
Section titled “Small changes create a large blast radius”For one new archive action, tests for Overview, Workspace, and Preview have to get adjusted, because they all instantiate the same store.
The store makes independent changes artificially dependent.
Query and invalidation rules diverge
Section titled “Query and invalidation rules diverge”The catalog depends on locale and publication scope.
The workspace depends on article ID, section ID, and edit context.
Keeping both in one shared query produces combinations that have nothing to do with each other in domain terms.
Source state gets copied
Section titled “Source state gets copied”The infrastructure adapter already provides safe signals for data, loading, and error.
The store additionally holds:
articles: Article[];articlesLoading: boolean;articlesLoaded: boolean;articlesError: string | null;Then two truths exist about the same read lifecycle.
Encapsulating httpResource doesn’t justify a second state machine in the store.
withComputed knows half the application
Section titled “withComputed knows half the application”If a page ViewModel is only understandable once you reconstruct Catalog, Images, Identity, Route, Locale, dialogs, and several pending states, the projection has probably gotten too wide.
None of these signals alone proves that a split is necessary.
Together, though, they show that the original owner has accumulated several reasons for change.
When splitting only produces overhead
Section titled “When splitting only produces overhead”The opposite case matters just as much.
A single store is allowed to stay one, when:
- only one view exists
- source and view always live together
- no other consumer needs the entities
- the view state stays small
- query and invalidation are unambiguous
- the
withComputedforms a coherent projection - a split would produce almost nothing but pass-throughs
A View Read Store with an encapsulated infrastructure source, selection, and ViewModel isn’t automatically mixed together.
If the selection belongs exclusively to this one view and the source has no other consumer, both might reasonably share the same lifecycle.
Then an additional Source Store would only lead to the View Read Store reassembling safe signals from a different file.
That’s not a boundary gained.
That’s distributed simplicity.
Architecture shouldn’t solve an anticipated problem that doesn’t exist yet.
A small use case is allowed to stay small.
What makes the split testable
Section titled “What makes the split testable”Architecture isn’t good just because its arrows look plausible.
The boundaries have to be verifiable.
Infrastructure adapter
Section titled “Infrastructure adapter”Tests:
- DTO→Entity mapping- technical hasValue()/value() guard- null versus a successfully loaded []- loading and error as safe signals- reload delegates to the private resource- no HttpResourceRef leaves the infrastructureSource Store
Section titled “Source Store”Tests:
- query activation- shared read state- result correlation- reload and invalidation- deactivation without a consumer- no copied resource stateView Read Store
Section titled “View Read Store”Tests:
- owned view state- Source+view-state→ViewModel projection- selection- expansion- dialog targets- read loading and write pending kept separate- no technical resource semantics in the storeCommand Executor
Section titled “Command Executor”Tests:
- request gets accepted or discarded- Started only emerges on actual acceptance- exactly one success or failure outcome- no UI state in the executorWrite-State Projection
Section titled “Write-State Projection”Tests:
- Started activates the matching target state- success and failure only end the matching target- mismatches don't change unrelated state- parallel operations stay separateComposition Root
Section titled “Composition Root”Tests the architecture invariant:
Write-state projections are activebefore an executor synchronously emits Started.Especially event-based projections can look correct and still hang off an untested bootstrap order.
An architecture without this safeguard stays a claim.
The concrete structure
Section titled “The concrete structure”One possible structure then looks like this:
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.tsThe folder names aren’t the rule.
The direction is decisive:
DTO → Entity → encapsulated source signals → optional source state → View Projection → Page ViewModel → PresentationFor writes:
Intent → Request → Command Executor → Started / Outcome → Write-State ProjectionThe infrastructure adapter doesn’t know a ViewModel.
The Source Store doesn’t know an Overview component.
The Overview Store doesn’t know an API payload or a HttpResourceRef.
The Archive Mapper doesn’t decide on HTTP invalidation.
The Command Executor doesn’t own UI state.
The View Read Store doesn’t know a Command Executor.
Presentation doesn’t interpret a resource.
Every boundary answers a different question.
Model boundaries are binding
Section titled “Model boundaries are binding”The store boundary can change with the application.
The model boundaries shouldn’t.
Even a small slice stays:
DTO → Entity → ViewModelMaybe entity and ViewModel initially live in a single Read Store.
That’s okay.
Maybe source state and view projection get separated later.
That’s also okay.
What shouldn’t happen:
DTO → everywhereOr:
Entity → the template decides the meaningOr:
HttpResourceRef → leaks into the store → the store reconstructs the technical lifecycleOr:
Read Store → Command ExecutorThat’s why the takeaway is:
Model boundaries are binding. Store boundaries follow real ownership and scaling needs.
Not every infrastructure adapter needs a Source Store.
Not every view needs its own View Read Store.
Not every growing file immediately needs to be split.
But when different lifecycles, consumers, and meanings share the same owner, you shouldn’t wait for the next thousand-line mark.
Conclusion
Section titled “Conclusion”A Read Store doesn’t become wrong just because the application grows.
It can, at some point, just own a boundary that no longer fits the application as it has evolved.
Then it’s not about cutting the store as small as possible.
It’s about making responsibility visible again.
The infrastructure adapter owns the concrete HttpResourceRef and its technical lifecycle.
The Source Store owns the shared domain source context, its query, invalidation, and shared entities.
The View Read Store owns the state and the projection of one concrete use case.
The Command Executor owns the execution of a change.
The Write-State Projection owns the visible lifecycle of a change that actually got accepted.
The page ViewModel is allowed to bring together source, view, and write state.
But bringing together doesn’t mean owning.
And it certainly doesn’t mean:
Read Store → Command ExecutorOr:
View Read Store → HttpResourceRefA large store can be coherent.
A small store can already contain several incorrect ownership boundaries.
That’s why the decisive question isn’t:
How many lines does the store have?
But:
Which states would we actually change, activate, and discard together when the same domain change occurs?
If this question no longer has a shared answer, the store probably isn’t too big.
Its ownership is too broad.