tars-monorepo · PR #137 · refactor/tanstack-query-migration

The server-state rewrite.

One typed server-state architecture across customer, market and admin — TanStack Query wired under Nuxt, a Query client generated from our controllers, and a marketplace that navigates instantly.

0
files changed
+12,359 −6,852
lines
0
granular commits
0
api-sdk tests
0
legacy async helpers left
96
Performance
92
Accessibility
96
Best Practices
100
SEO

agorastore.fr, after the migration. Lighthouse on the live marketplace — server-rendered, hydrated from one Query cache.

01

Why

Three apps, three ways to fetch, zero shared memory. Every screen paid the full price of every navigation.

1.1Life before: three async layers

Server data flowed through useAsyncState, useAsyncMutation and Nuxt’s useAsyncData — three overlapping abstractions, each reinventing loading flags, refresh wiring and error handling. Nothing was cached. Navigating back to a page you saw four seconds ago refetched everything, behind a blocking spinner.

Worse, the state itself was duplicated: stores mirrored server responses into local refs, watchers synchronized the copies, and every mutation ended with someone remembering — or forgetting — which list to refresh by hand.

the old reflexes, everywhere
// fetch: one-shot, uncached, per-component
const { data: addresses, pending, refresh } = useAsyncState(
  () => $api.addresses.getAddresses(userStore.organisationShortId),
  { default: [] },
)

// mutate: then manually refresh whatever might have changed
const changed = await action()
if (changed) tableApi.refresh()

// store: mirror the server value, then keep the copies in sync
const user = ref<User | null>(null)
watch(sessionUser, (value) => { user.value = value })

1.2The bet

Make TanStack Query the single owner of server state — and make it invisible to write. We keep Nuxt. We keep our controllers as plain, inferred TypeScript. What changes is that keys, query options, cache writes and invalidation rules are generated from the controllers instead of hand-written at every call site.

Nobody hand-writes a query key, a cache write, or an invalidation rule anywhere in application code.

1.3What “migrated” means

Not a pilot, not a coexistence layer. The migration is complete across application code — useAsyncState, useAsyncMutation and useAsyncData no longer exist in the migrated runtime surface.

SurfaceStatusCovered behavior
customer-frontMigratedSession bootstrap, stores, seller tables & grids, drafts, appointments, onboarding, forms, actions, autosave
market-frontMigratedSSR pages, catalogs & facets, Prismic, query-owned page context, progressive item detail, PubNub patches, route & catalog prefetching
admin-frontMigratedEntity tables, detail queries, forms, actions, reports, remote option sources
shared-businessMigratedQuery runtime, per-app configuration, shared stores, SSR hydration, prefetch orchestration, cancellation & error policy
shared-uiIntegratedShared useQuery, page-context binding, DataList, catalog compiler, form options, devtools, loading states
api-sdkIntegratedTyped raw & Query clients, standardized operations, generated keys & options, signals, controller invalidation
02

The wins

Three audiences, three payoffs: the visitor gets speed, the developer gets a client that writes itself, the user gets an interface that never goes blank.

2.1Performance — market-front flies

The marketplace is now aggressive about doing work before you ask for it and never doing it twice. The server renders from a per-request Query cache, dehydrates it into the page, and the browser hydrates the same cache — so the client never refetches what the server already fetched. From there, smart prefetching warms the exact queries of the page you’re about to visit.

Initial load
SSR without the double fetch

Dehydrate on the server, hydrate on the client. SEO metadata, breadcrumbs and content read the same cache the page renders from.

Navigation
Instant, almost everywhere

Hovering a link prefetches the destination’s component and its exact data plan. By the time you click, the page renders from cache.

Revisits
Cache-aware suspense

Cached pages render immediately while stale entries revalidate in the background. Going “back” costs zero network round trips.

Waste
Obsolete work gets cancelled

Every generated query forwards its AbortSignal. Rapid filter, sort or route changes kill stale requests instead of racing them.

2.2Developer experience — the client writes itself

Classify a controller method as a query or a mutation, and the SDK generates everything else: typed keys, queryOptions, mutationOptions, direct calls and same-controller invalidation. Types flow from the controller signature into the cache — getQueryData, setQueryData, prefetch and invalidation are all inferred, end to end.

one line of app code = key + fetch + cache + types
const { data: item } = useQuery(() =>
  $client.items.get.queryOptions({ input: itemShortId.value }),
)

2.3User experience — nothing blocks anymore

Initial loading and background refresh are now deliberately different states. A first visit keeps the existing skeletons; everything after happens behind the data you already see. Tables keep their rows interactive under a thin progress bar, grids get a compact floating spinner, and mutations refresh mounted data silently. Blank table shells are gone.

03

Architecture

Four owners, one cache. Each layer does the thing it’s uniquely positioned to do — and nothing else.

3.1Three layers, one cache

Application app/config/query.ts
stale time · GC · retry · focus policy
apps
Query runtime
per-request QueryClient · $api / $client · SSR dehydrate → hydrate · error & cancellation policy
shared-business
Generated operations
typed queryOptions · mutationOptions · data-tagged keys · controller invalidation
api-sdk
Vue-facing sugar
useQuery · page context · DataList & catalog engines · form option sources · devtools
shared-ui
  • Apps decide cache behavior, because customer, admin and market have different freshness requirements.
  • shared-business creates and provides the clients, because business stores and plugins call the API before app components exist.
  • shared-ui resolves the provided QueryClient for tables, catalogs, forms and the shared useQuery — it never creates a second cache.

3.2The three client surfaces

SurfaceUse it forCache behavior
$clientNormal API reads and commands — the default pathGenerates typed Query options and keys; mutations invalidate their controller’s active queries
$apiRaw transport where cache behavior would be wrong or handled elsewhereNo cache read, write or invalidation — deliberately inert
$queryClientExplicit orchestration: prefetching, cross-controller effectsDirect access to the application-owned QueryClient

Keeping $api visible rather than hiding it behind Query is a design choice: cache side effects are opt-in at the client surface, and exceptional flows keep a clean escape hatch.

3.3Each app owns its cache policy

Same architecture, different freshness. Admin wants near-real-time and refetch-on-focus; the marketplace tolerates 30 seconds of staleness in exchange for instant navigation.

30s
stale time
30min
GC time
Off
focus refetch
Public catalog data changes slowly; long GC keeps prefetched pages warm across a whole browsing session.
apps/market-front/app/config/query.ts
export const queryConfig = defineQueryConfig(() => ({
  queryClient: new QueryClient({
    defaultOptions: {
      mutations: { retry: false },
      queries: {
        gcTime: 30 * 60_000,
        refetchOnWindowFocus: false,
        retry: 1,
        staleTime: 30_000,
      },
    },
  }),
}))
15s
stale time
15min
GC time
Off
focus refetch
Seller workflows need reasonably fresh data without yanking editable state out from under the user.
5s
stale time
10min
GC time
On
focus refetch
Back-office operators live in multiple tabs; switching back revalidates whatever they’re looking at.
04

The generated client

Controllers stay plain TypeScript. The SDK turns them into a fully typed Query client — keys, options, calls and invalidation included.

4.1Classify, don’t rewrite

The only thing a controller author declares is whether a method is a query or a mutation. Parameters and results are inferred from the methods themselves — no schema DSL, no codegen step, no duplicate type declarations.

packages/api-sdk/src/api/controller/item.controller.ts
export const ItemController = createController((clientLoader) => {
  const httpClient = clientLoader([...itemsApiSchema, ...searchApiSchema])
  return {
    queries: {
      get(itemShortId: string) {
        return httpClient.getItemById({ params: { itemShortId } })
          .then((items) => items[0])
          .then((item) => (item ? normalizeItem(item) : item))
      },
      search(params: z.infer<typeof searchParamsDto>) {
        return httpClient.searchItems(params).then(normalizeSearchItemsResult)
      },
      searchFilters(params: z.infer<typeof filtersSearchParamsDto>) {
        return httpClient.searchItemsFilters(params)
      },
    },
    mutations: {
      update({ itemShortId, item }: { itemShortId: string; item: UpdateItem }) {
        return httpClient.updateItem(item, { params: { itemShortId } })
          .then((res) => res?.[1]?.[0])
      },
    },
  }
})

Note where search lives: search endpoints now belong to the entity controller they return. The former standalone search controller is gone — so item detail, item tables and item catalogs all share the same controller invalidation boundary for free.

4.2Every operation, four capabilities

generated — nothing here is hand-written
// every query operation exposes:
$client.items.get.call(itemShortId)                    // direct typed call
$client.items.get.key()                                // operation prefix key
$client.items.get.queryKey({ input: itemShortId })     // exact, data-tagged key
$client.items.get.queryOptions({ input: itemShortId }) // plug into any Query API

// every mutation operation exposes:
$client.items.update.call({ itemShortId, item })  // call + auto-invalidation
$client.items.update.mutationKey()
$client.items.update.mutationOptions()            // plug into useMutation

Input follows the controller signature instead of forcing everything into envelopes: no argument means no input, one argument passes directly, several pass as a tuple. Mutations take one variables object, so useMutation always sees a stable variables type.

4.3Keys as a typed hierarchy

Keys are generated, hierarchical and data-tagged — the cache knows what type lives at each key, so getQueryData, setQueryData, invalidation and prefetch all infer their data and error types.

['api']← namespace & collision boundary   ['api', 'items']← invalidate a whole controller     ['api', 'items', 'get']← invalidate one operation       ['api', 'items', 'get', ['x7-3fq']]← exact entry, typed as Item

Generated-client tests protect exact and partial keys, uniqueness across every controller operation, zero/one/multiple argument signatures, AbortSignal forwarding, result inference, mutation invalidation and invalidation opt-out — 497 tests across 46 files.

4.4One naming grammar

Controller names carry the entity, so operations stay short and consistent across all three apps. Renames were atomic across controllers and consumers — an alias would give one request two cache identities.

IntentConventionExample
Primary detailget$client.organisations.get
Ordinary collectionlist$client.organisations.list
Full-text searchsearch$client.items.search
Advanced DB queryquery$client.organisations.query
Base mutationcreate / update / delete$client.organisations.update
Alternate lookupgetByX$client.organisations.getBySlug
Bulk mutationverb + Many$client.items.updateMany
Domain commandsemantic verb$client.organisations.block
05

Mutations & invalidation

The end of “remember to refresh the table”. Mutations know which server state they touched — because the controller told them.

5.1Two mutation paths

Use .call() when a command only needs its result. Use useMutation when the component needs pending state, optimistic lifecycle hooks, serialization scope or local reconciliation:

customer-front · draft save, serialized per draft
const { isPending: isSaving, mutateAsync: saveDraft } = useMutation(() =>
  $client.drafts.update.mutationOptions({
    scope: { id: `draft-${itemShortId.value}` },
    onSuccess(updated, variables) {
      if (!updated) return
      setItem((current) =>
        current && isDirty(variables.item, current)
          ? mergeObjects(updated, current)
          : updated,
      )
    },
  }),
)

5.2Invalidation you never write

Both paths invalidate active queries under their own controller after a successful mutation. Invalidations are batched over a 16 ms frame and use refetchType: 'active' — mounted data refreshes, inactive entries just become stale. No background traffic storms.

BEFOREmanual refresh choreography
const changed = await action()
if (changed) tableApi.refresh()
// ...and hope nothing else
// was showing item data
AFTERthe mutation knows
await $client.items.performAction.call({
  itemShortIds,
  action: 'PUBLISH',
})
// mounted item tables, details and
// catalogs refetch — automatically

Because search, detail reads and supporting item queries all live under items, one publish command refreshes the mounted item table and any open detail observer — automatically. A controller can still opt out per mutation with invalidates: { mutationName: false } when refetching would be wrong.

5.3When explicit cache work is still right

ScenarioPattern
Same-controller mutationRely on generated invalidation
Immediate visible reconciliationonSuccess, writable query data, or the table API — in addition
Cross-controller effectExplicitly invalidate via useQueryClient()
Raw $api commandHandle cache effects yourself — raw transport is intentionally inert
External event (PubNub)Patch the exact query through its setData helper
06

Application patterns

One shared useQuery, auto-imported everywhere. Native TanStack observer underneath, plus exactly the sugar that made the old composables useful — and nothing more.

6.1useQuery, the app-facing API

BEFOREuseAsyncState — uncached, per-component
const { data: addresses, pending, refresh } = useAsyncState(
  () => $api.addresses.getAddresses(userStore.organisationShortId),
  { default: [] },
)
AFTERshared, cached, cancellable, deduplicated
const { data: addresses, isLoading: pending, refetch: refresh } = useQuery(
  () => $client.addresses.list.queryOptions({
    input: userStore.organisationShortId,
  }),
  { defaultValue: [] },
)

defaultValue kills the fallback computeds, native enabled handles conditions, and native select keeps derivation with the query that owns it. What used to take a query variable plus two extra computeds is now one declaration:

customer-front · active contracts for the current shop
const { data: contracts, isLoading: loadingContracts } = useQuery(
  () => $client.contracts.list.queryOptions({
    enabled: Boolean(organisationShortId.value),
    input: organisationShortId.value,
    select: (loaded) => loaded.filter((contract) =>
      contract.indexedMeta?.status === 'ACTIVE' &&
      contract.shopShortIds.includes(shopStore.currentShopId),
    ),
  }),
  { defaultValue: [] },
)

6.2Writable query data

When select preserves the result shape, data becomes a writable model over the cache — with exact-key helpers that always resolve the current reactive key. This replaced hand-written computed setters and repeated setQueryData plumbing across all three apps.

customer-front · session store
const { data: user, patchData: patchUser, clearData: clearUser } = useQuery(
  () => $client.auth.loggedUser.queryOptions({
    enabled: Boolean(beaconToken.value),
  }),
  { defaultValue: null },
)

user.value = updatedUser        // writes through to the cache
patchUser({ firstName: 'Ada' }) // deep-partial patch, typed
clearUser()                     // removes the exact entry

If select changes the shape, data is readonly — writing a selected shape into a cache holding the raw shape would be unsafe, and the type system enforces that.

6.3Query-local effects

onData and onError replace the watchers whose only job was reacting to one query — navigation guards, emits, form prefills. They observe hydration, background updates and local cache writes through the same observer.

customer-front · draft page guard
const { data: item } = useQuery(
  () => $client.drafts.get.queryOptions({ input: itemShortId.value }),
  {
    defaultValue: null,
    onData(item) {
      if (!includes(item.status, ['DRAFT', 'TO_MODIFY']))
        return navigateTo({ name: 'seller-drafts' })
    },
    onError: () => showError({ statusCode: 404 }),
  },
)

6.4Query-owned page context

Route-shell data — breadcrumb entities, page titles — used to be mirrored into context by watchers. Now the query that owns the data is the context: pageContext: true binds the result to the route, a string contributes a named value, a selector exposes only what the shell needs.

market-front · composing one context from several owners
const { data: seller } = useQuery(
  () => $client.organisations.getBySlug.queryOptions({ input: sellerSlug.value }),
  { pageContext: 'seller' },
)

const prismic = usePrismicContent()
const { data: document } = useQuery(prismic.byUidQueryOptions('page', uid.value), {
  pageContext: (doc) => ({ title: asText(doc.data.title) }),
})

The binding updates synchronously from the observer, so SSR hydration, prefetched cache data, normal fetches and later cache writes all flow through one path. Server-rendered dynamic breadcrumbs come from the same mechanism — no second data-loading abstraction.

6.5Stores stop mirroring

Remote values live in the Query cache, full stop. Pinia stores keep what they’re actually for — business derivation, session orchestration, access helpers — and write through the query result instead of synchronizing a second ref. User, active organisation, contracts, shop, categories, reference data: all cached server values now, zero watchers keeping copies aligned.

07

market-front, the speed run

The marketplace is the performance-critical surface, and it got a dedicated treatment: one cache from server to client, and a prefetch pipeline that makes navigation feel ridiculous.

7.1One cache, server to client

Every SSR request gets a fresh QueryClient. After rendering, the runtime dehydrates it into the payload; the browser hydrates the same cache before any observer runs. The client never refetches what the server already loaded — and SEO metadata, structured data, canonical URLs and breadcrumbs derive from those same hydrated values instead of running a parallel content path.

packages/shared-business/app/plugins/api-client.ts
const { queryClient, pluginOptions } = await app.runWithContext(() => queryConfig({ client }))
bindQueryClient(queryClient)

app.vueApp.use(VueQueryPlugin, { ...pluginOptions, queryClient })

if (import.meta.server)
  app.hook('app:rendered', () => {
    queryState.value = dehydrate(queryClient)
  })

if (import.meta.client && queryState.value) hydrate(queryClient, queryState.value)

Prismic follows the same model — singleQueryOptions, firstByTypeQueryOptions and byUidQueryOptions carry the active language in their keys, so pages and prefetching always address the same cache entry. Item detail renders its cached primary entity immediately and lets secondary data resolve progressively; PubNub live updates patch the query result directly instead of maintaining a parallel copy.

7.2defineQueryPrefetch — data declared beside the route

Pages declare their navigation-critical data with a macro. The route name is checked against Vue Router’s generated RouteMap, so route.params is inferred for that exact page. A build transform attaches the declaration to route metadata — consumers never call prefetchQuery themselves.

apps/market-front/app/pages/item/[itemShortId].vue
defineQueryPrefetch('item-itemShortId', ({ client, route }) => [
  client.items.getFull.queryOptions({ input: route.params.itemShortId }),
  client.characteristics.list.queryOptions(),
])

One shared prefetchPage(to) entry point preloads the destination component and runs its data declaration through the existing QueryClient. NuxtLink’s native link:prefetch hook calls it automatically on hover or focus; non-link interactions call it directly — the header search bar warms the search page the moment you focus the input:

apps/market-front/app/components/layout/AppHeader.vue
function prefetchSearchPage() {
  return prefetchPage({ path: localePath({ path: '/search' }) })
}

Matching work is deduplicated by Query key and destination fullPath. And because the destination page uses the same generated query options, navigation stays correct when a prefetch never fires — it just falls back to a normal fetch.

7.3The catalog prefetch compiler

Catalog pages are the hard case: rows depend on route filters, facets, sorting, pagination and context queries. The catalog now exposes a first-class prefetch compiler — prefetchCatalog reads the destination query string and the schema’s defaults, then executes a staged plan: context queries resolve first, then the compiler derives the exact request and warms rows, facets and query-backed filter options.

apps/market-front/app/pages/category/[categoryShortId].vue
defineQueryPrefetch('category-categoryShortId', ({ client, route }) => [
  client.categories.get.queryOptions({ input: [route.params.categoryShortId] }),
  prefetchCatalog({
    route,
    schema: itemCatalogSchema({ categoryShortIds: [route.params.categoryShortId] }),
  }),
])

The mounted catalog uses those same resolver functions — prefetch and rendering cannot drift into different keys or payloads. Staged plans run through ensureQueryData({ revalidateIfStale: true }): cached destinations render immediately while stale values refresh behind them.

7.4Prefetch policies, per link

NuxtLink stays the policy surface, so each link decides when its route’s declaration runs:

market-front · the three policies
<!-- default: prefetch on pointer/focus interaction -->
<NuxtLink :to="itemUrl" />

<!-- small, high-value rails: prefetch as cards enter the viewport -->
<ItemCard :item="item" prefetch-on="visibility" />

<!-- explicit opt-out where speculation isn't useful -->
<NuxtLink :to="target" no-prefetch />
  • Item destinations prefetch their primary detail; category, seller, auction, search and catalog destinations prefetch the exact catalog plan derived from the destination route.
  • Editorial destinations (landing, FAQ, support, become-seller) prefetch their locale-aware Prismic documents.
  • Featured and related item rails use viewport prefetching; dense catalogs keep interaction policy to avoid fetching every visible result.
  • A successful prefetch only warms the cache — no page mount, no observer. If it misses, the page fetches normally.

7.5Try it: hover to warm

This is a simulation of the real pipeline — link:prefetchprefetchPage() → QueryClient. Hover the card, watch the cache warm, then navigate. Then reset and navigate cold to feel the difference.

Simulation · NuxtLink → prefetchPage → QueryClientcache: cold
Chargeuse compacte Bobcat S570
/item/x7-3fq · Lyon (69)
12 400 €
→ hover the card, like a real visitor would
// waiting for interaction…

7.6The long tail

The migration exposed rendering costs the old blocking loaders used to hide — so the perf pass went deep:

  • Stable row references — table cells don’t rerender for observer-only state changes; rowKey supplies identity directly.
  • Shallow ownership for row payloads and Query observers where deep proxies add nothing.
  • Cache-aware suspense() — blocks only a cold navigation; revisiting prefetched pages costs zero round trips.
  • Deferred hydration — menus, footer and lower-page Prismic slices hydrate after the critical shell.
  • Responsive hero images — the browser picks the right source instead of downloading desktop assets on mobile.
  • Debounced search + cancellation — page reset happens before key derivation, so no intermediate request for the previous page with new filters.
  • Lazy wizard steps — form schemas and their option observers mount on first visit, not at wizard creation.
  • Prebundled sanitizer — removes first-use stalls; grid rendering stays virtualized.
08

Tables, forms & drafts

Customer and admin got the same engine upgrade: schemas declare where data comes from, the engine owns everything else.

8.1DataList: declarative sources

A table schema declares its source; the engine owns Query observation, cancellation, route state, pagination, sorting, filter payloads and loading semantics. Two modes exist because the product uses two — the untested generic adapter was deleted, not preserved.

remote / Elasticsearch rows
source: defineTableSource({
  mode: 'remote',
  query: ({ payload }) =>
    $client.items.search.queryOptions({
      input: payload,
    }),
})
client-side rows
source: {
  mode: 'client',
  query: () =>
    $client.addresses.list.queryOptions({
      input: organisationShortId,
    }),
}

Catalog uses the same model with marketplace extras — independently cacheable rows and facets, previous nonzero facets preserved during background validation, and route-scoped query writers that stop writing the moment navigation leaves the owning route.

8.2Loading vs refreshing

StateData visibleTreatment
Initial request, no usable dataNoExisting blocking skeleton / spinner
Background validation with cached dataYesNon-blocking secondary indicator
Table background validationRows stay interactiveThin progress bar that advances, completes, fades
Grid background validationCards stay interactiveCompact floating spinner
Catalog background validationCards stay interactiveCompact floating spinner above the grid

Refresh button, filter changes, sorting, pagination, action-triggered invalidation and focus validation all share the same isFetching state. The engine also preserves prior rows during key changes — no more content disappearing behind a loader.

8.3Form options are queries now

Remote select options return Query options, so the form engine understands dependencies, loading, cancellation, caching and refresh — without every field reimplementing those concerns.

BEFOREad hoc async, refetch on any dependency
options: async ({ deps }) => {
  if (!deps.manufacturerShortId) return []
  return $api.models.getModels({ manufacturerShortId: deps.manufacturerShortId })
}
AFTERdeclarative — refreshOn picks what reloads
options: {
  refreshOn: ['manufacturerShortId'],
  source: ({ deps }) =>
    $client.models.list.queryOptions({
      enabled: Boolean(deps.manufacturerShortId),
      input: { manufacturerShortId: deps.manufacturerShortId },
      select: (models) =>
        models.map(({ modelShortId: value, name: label }) => ({ value, label })),
    }),
  externalDependencies: [$i18n.locale],
}

In the draft form, changing the category can enable or disable manufacturer and model fields without discarding their options; changing the manufacturer reloads models. Obsolete option requests get cancelled, previous options stay visible while the next set loads, invalid selections are removed after the authoritative set arrives, and fields sharing a generated key share the cached data.

8.4The draft editor & autosave

The writable query is the canonical editable draft. Autosave snapshots it, the save mutation serializes per draft via scope, and successful responses merge without overwriting edits made while the request was in flight. Focus and reconnect refetch are disabled during editing, and useAutoSave now schedules a trailing save when you edit during an in-flight request — the last local change can’t be lost.

09

What this unlocks

The migration pays for itself today. But the reason it’s shaped this way is what comes next.

9.1Agorastock: the inventory app, offline-ready by design

The next product is a warehouse inventory app — used on the floor, on flaky networks. Before this PR, that meant building a bespoke offline stack. Now server state lives in one QueryClient behind one typed client, and TanStack Query’s persistence and network-mode capabilities plug in as app-level configuration, exactly like each app already configures stale time and GC today.

Offline
Persistence is a config decision

The architecture leaves QueryClient persistence open per app — cache to storage, restore on launch, no new data layer.

Network mode
Flaky Wi-Fi, handled

Query’s network-mode semantics — pause, resume, retry — apply to every generated operation for free.

Reuse
The draft engine comes along

Writable query data, serialized mutations and trailing autosave are the exact primitives an inventory workflow needs. No re-implementation.

Day one
Forms, tables, actions: free

DataList, FormRenderer and the generated client are app-agnostic. A fourth app starts from the full toolkit.

Scope honesty: offline persistence is deliberately not implemented in this PR. The point is that it no longer requires an architecture change — only configuration and app-specific policy.

9.2The paths now open

  • Optimistic updates, selectively — the plumbing exists; if the backend converges list and detail on one canonical entity shape, normalized optimistic updates become straightforward.
  • Cross-controller invalidation graph — explicit today by design; the key hierarchy is ready if a declarative graph ever earns its complexity.
  • Observability for free — Query devtools are mounted through shared AppRoot in development, resolving the real application cache.
  • Per-app tuning forever — freshness, retries and focus behavior are one config file per app, not a rewrite.
10

Shipping & proof

A migration this size doesn’t get to be “probably fine”. It was validated layer by layer, app by app, in the browser.

10.1Validation

  • Repository lint and format check
  • api-sdk: 497 tests across 46 files — keys, inference, signals, metadata, invalidation and opt-out
  • shared-ui suites: query observer, DataList, catalog prefetch, facets, form options
  • shared-business suites: Query runtime, route-prefetch transform, shop cache hydration
  • Production builds for all three apps
  • Browser verification — customer: session, tables, filters, sorting, pagination, refresh indicators, drafts · market: catalogs, facets, item detail, route behavior · admin: migrated entity screens

10.2CI & deployments

The final CI run is green across dependency policy, formatting, package builds, lint, typechecks, tests and production images. Everything is live on Integration and UAT — production deliberately waits for review.

ApplicationImageIntegrationUAT
customer-frontv278DeployedDeployed
market-frontv269DeployedDeployed
admin-frontv287DeployedDeployed

10.3How to review 99 commits

The commits are granular on purpose. The contract layers come first — every consumer depends on them:

  • 1 · api-sdk — controller classification, key hierarchy, inference, signal forwarding, invalidation
  • 2 · shared-business runtime — per-app config, client provision, SSR dehydrate/hydrate, cancellation
  • 3 · shared-ui query API — writable data, defaults, effects, selected-data safety
  • 4 · shared-ui abstractions — DataList, catalog, form options, loading semantics
  • 5 · customer-front — the reference implementation for stores, drafts, forms and tables
  • 6 · market-front — SSR, facets, hydration, page context, prefetch policies
  • 7 · admin-front — same patterns, no separate admin model
  • 8 · skills & tests — the contracts future changes are expected to follow

10.4Deliberate boundaries

Just as important as what changed is what didn’t:

  • Nuxt stays. Smart prefetching composes Nuxt’s own route and component preload lifecycle — no framework change.
  • Controllers stay plain TypeScript. No RPC declaration DSL; changing the authoring model didn’t earn its migration cost.
  • Pinia stays for business state and orchestration; Query owns server state.
  • Cross-controller invalidation stays explicit. Automatic same-controller invalidation covers the dominant case without a second dependency graph.
  • Offline is deferred, not blocked. The architecture leaves persistence open as app configuration.