Data Flow
This page traces how entity data moves between the backend, the cache, the list controller, and the workspace UI — and how mutations flow back the other way without manual refresh wiring. The unifying idea is mutation announcement + subscription: writers announce what changed, the cache service brokers the announcement against everyone who subscribed, and controllers re-read.
Read path
EntityListController<T> holds the query lifecycle (page, page size, search, sort, filters, drafts toggle) as raw MobX observables and uses cached_query_flutter for stale-while-revalidate streaming. The controller exposes a Stream<QueryStatus<List<T>>> that EntityView(query:) subscribes to. While the cache reissues a request in the background, EntityView keeps showing cached data and surfaces a LinearProgressIndicator.
EntityView<T> accepts four data-source modes: future, stream, data, and query. The query mode is the default for list / detail surfaces because it integrates with the cache's invalidation events.
EntityApi and HttpEntityApi
EntityApi<T> is the abstract CRUD contract:
getOne,getMany,create,update,delete,count,findPosition.getDrafts,countDraftsforDraftableentities.entityType,schemaType,defaultSortField,hasDrafts.
HttpEntityApi<T> implements it over REST. It composes:
- An
EndpointBuilderthat constructs URIs (getMany,getOne(id),create,update(id),delete(id),count,findPosition(id)and custom paths viabuildUri). - A
CachingPolicy(staleDuration,excludedOperations) — override on your subclass to change behaviour, or useCachingPolicy.noneto disable. - A
CacheKeyBuilderthat namespaces every key underentityType.
For non-CRUD endpoints (workflow transitions, custom actions), HttpEntityApi exposes typed helpers (post, put, patch, plus mutate(uri:, method:, body:, announces:)) that take a Set<Mutation> to announce on success. This is how a custom workflow API keeps the cache coherent without touching the read site.
The cache service
QueryCacheService is the central facade. It runs two cooperating caches:
- Lightweight one-shot cache —
Map<String, _CacheEntry>plus in-flight deduplication. Used byHttpEntityApi.cachedGet. - Reactive observable cache — backed by
cached_query_flutter. Used byQueryCacheService.observe(...)to produceObservableStream<QueryStatus<T>>for MobX widgets.
Both caches share a single subscription / announcement registry, so a mutation invalidates entries regardless of which path produced them.
Mutation announcement and subscription
Mutation carries (entityType, MutationType, entityId?). The three types are create, update, delete. A subscription with a non-null entityId matches only that id (or an entity-wide announcement). Mutation.any(type) matches any MutationType for the type.
HttpEntityApi.create / update / delete automatically fan out the right announcements. Custom write endpoints declare their announcements explicitly through the announces: parameter on post / put / patch.
InvalidationEvent stream
QueryCacheService.invalidations is a broadcast Stream<InvalidationEvent>. Every workspace template subscribes to it through WorkspaceTemplate's shared infrastructure and dispatches typed MutationEvents (EntityCreated, EntityUpdated, EntityDeleted) to the workspace's onAfterMutation callback. This is how the UI auto-refreshes after CRUD operations or SSE pushes — no manual refresh() calls needed.
EntityListController
EntityListController<T> owns four concerns: query lifecycle, query params, selection, and counts. It deliberately does not own detail data, tab state, or panel control — those belong in widgets.
Key state (raw Observables, never exposed directly):
- Query:
_currentQuery,_querySub,_queryStream,_isLoading,_entities. - Params:
_currentPage,_pageSize,_searchQuery,_sortField,_sortAscending,_showDrafts, plus anEntityFilterState. - Selection:
_selectedEntityId,_selectedEntityIds,_selectedEntityDetail,_enableMultiSelect. - Counts:
_totalCount.
A baseFilter (always-applied, invisible) lets a workspace scope itself to a fixed slice (e.g. an Inbox tab restricted to a particular activity type) without polluting the user-facing filter UI. The controller also subscribes to QueryCacheService.invalidations and re-issues the current query when its entity type is invalidated.
Drafts and versioning
For entities with Draftable, EntityApi.getDrafts and countDrafts power the "Drafts" tab. EntityListController exposes a _showDrafts toggle that swaps the underlying call. Each entity comes back hydrated with a DraftMetadata payload (status, operation, workflow link, revision comments, canEditDraft); UI uses these to drive draft badges, banners, and edit gating.
The full lifecycle (draft → underReview → approved → effective, or rejected / cancelled / failed terminal states) is described on the drafts & versioning guide.
EntityVersion and EntityAudit records back the version-history and audit-trail tabs. Both expose user attribution; the EntityNameCacheService resolves display names client-side without per-row API calls.
Editor write path
A StandardEntityEditor<T> calls api.create or api.update directly. A SignatureDrivenEditor<T> first resolves a LifecycleRequirements value, and when verification is required it wraps the save inside the signature service so remarks flow back into the API call:
When approvals are configured for the operation, SignatureDrivenEditor short-circuits verification and saves to draft instead — verification happens later through the workflow system.
Realtime updates
EntityRealtimeService is the polling-based realtime channel registered by the plugin. Features that need push semantics can wire SSE or WebSocket sources to call QueryCacheService.invalidateEntity(entityType) or invalidateById(entityType, id). From there the existing subscription path takes over and everything that observes those keys re-fetches.
Error handling
HttpEntityApi surfaces failures as ApiResponseException (or richer typed errors when the backend returns an ApiErrorResponse envelope). The UI package's ErrorDisplayService resolves any error into a DisplayableError for consistent rendering. Always surface failures through ErrorResolver.resolve(e).message and showApiErrorDialog — never raw exception strings, and never SnackBar (the app forbids it).
Next steps
- Layouts — how list / detail / aggregate layouts consume the controller.
- CRUD Operations — implementing a new
HttpEntityApisubclass. - Drafts and Versioning — full lifecycle.
- Building UI — wiring
EntityListViewandEntityViewend-to-end.