ADR-0013: Adapter-Store Pattern over Pinia
Accepted Issue TrackerDate: 2026-03-08
Context
The frontend needs reactive state management for domain resources (projects, issues, sprints, etc.) that are fetched from a REST API, displayed in components, edited locally, and synced back.
Every domain in this application follows an identical pattern:
- Fetch a collection from a REST endpoint
- Display items in components (read-only by default)
- Allow local edits via a mutable copy
- Sync changes back via REST (create, update, delete)
- Handle optimistic updates and error rollback
Pinia is the standard Vue 3 state management library. Using Pinia would mean writing a full store definition for each domain, despite every store having the same shape.
Decision
We use a custom adapter-store pattern (services/adapter-store.ts + services/adapter.ts) instead of Pinia.
A factory function adapterStoreModuleFactory() generates a complete reactive store from a domain name and adapter configuration. Each domain's store.ts is ~20 lines of factory invocation, not a full store definition.
How It Works
Adapter (services/adapter.ts): Handles REST communication for a domain. Knows the API endpoint, handles camelCase ↔ snake_case transformation, constructs request payloads.
Adapter-Store (services/adapter-store.ts): Wraps an adapter with reactive state. Provides:
getAll— reactive list of all itemsgetById(id)— reactive single item lookupgetOrFailById(id)— throws if not foundgenerateNew()— create a blank item with defaultsretrieveAll()— fetch from API and populate store
Adapted Items: Each item in the store exposes:
mutable— returns a writable copy for editing (original stays frozen)reset()— discard local edits, revert to server stateupdate()— sync mutable copy back to serverdelete()— remove from server and storecreate()— persist a new item to server
Why Not Pinia
- Repetition: 15+ domains with identical store shapes would mean 15+ nearly-identical Pinia store files
- Enforced immutability: The adapter-store freezes originals and forces edits through
.mutable. Pinia has no built-in concept of frozen-original + mutable-copy - Automatic REST integration: The adapter handles API calls, transformation, and store updates in one flow. With Pinia, each store would manually wire up axios calls
- Convention enforcement: The factory ensures every domain follows the same API. No store has a custom
fetchItems()vsloadAll()vsgetItems()naming divergence
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Pinia stores per domain | Rejected | 15+ domains with identical shapes = 15+ nearly-identical files. No built-in immutability enforcement. |
| Custom adapter-store factory | Accepted | ~20 lines per domain. Enforced immutability. Automatic REST integration. Convention enforcement by construction. |
Consequences
Positive
- Adding a new domain requires ~20 lines of
store.tsconfiguration, not a full store definition - All domains share the same API surface — consistent patterns across the entire frontend
- Immutability is structural: you cannot accidentally mutate a store item without calling
.mutable - camelCase ↔ snake_case conversion is automatic and invisible to components
- Tests follow a single pattern: mock the store from
__mocks__/store.ts, assert on component behavior
Negative
- External documentation and tutorials don't apply — the pattern is fully custom
- AI agents must be told about this pattern explicitly or they will suggest Pinia
- Debugging state issues requires understanding the adapter-store internals
- No Vue DevTools integration for state inspection — debugging requires breakpoints
- The factory abstracts away store creation, which can make it harder to understand the reactive flow for newcomers
References
- Key files:
frontend/src/services/adapter-store.ts,frontend/src/services/adapter.ts - Domain-Driven Frontend Structure — defines how domains are organized