Designing Around the Ember Data Store: Identity Map, Adapters, and Where the Trust Boundaries Sit
An architecture note on Ember Data's Store: the identity map, adapter/serializer trust boundaries, caching pitfalls in 5.x, and the failure modes that decide whether the classic design still fits.
23 Feb 2026, 22:41 UTC

The problem the Store actually solves
If your Ember app fetches the same post record from a route, a component, and a service, you have three choices: let each caller keep its own copy (and drift out of sync), pass one copy around by hand (and couple everything), or centralize. Ember Data's Store exists for the third option. The useful takeaway: treat the Store as the single owner of record state, keep network details behind adapters and serializers, and never mutate records outside the Store's tracked setters. Everything else in this note follows from those three rules.
This applies to Ember Data 4.x and 5.x with the classic @ember-data/model setup. Newer request-manager-based APIs change some details; verify against your installed version with ember version before relying on specifics below.
Requirements
- One canonical copy of each record, identified by type plus id, so two references to
post:1are the same object. - Batched, tracked mutations so a form can edit ten attributes and commit them as one save.
- Network concerns isolated behind swappable adapters (REST, JSON:API, GraphQL) so routes and components only see records and promises.
- Deterministic behavior when the server rejects a write — the record must land in a known error state, not limbo.
The smallest suitable design
Three pieces cover it:
- Store with identity map. The Store keys records by
type + id. Internally each record is wrapped in an InternalModel that tracks state (isLoaded,isSaving,hasDirtyAttributes). You can confirm identity with one line in the browser console or a test:
// In a route, component test, or console with the store available
const a = store.peekRecord('post', 1);
const b = store.peekRecord('post', 1);
console.assert(a === b, 'identity map broken');- Adapter per host. The adapter answers "how do I fetch or save this type against this backend?" Subclass
RESTAdapterorJSONAPIAdapterand overridehost,namespace, orpathForType. One adapter per backend, not per model, unless a model genuinely lives elsewhere. - Serializer per payload shape. The serializer normalizes server payloads into the Store's internal format and back.
JSONAPISerializeris strict: it expects dasherized attribute keys and{ type, id }relationship objects. A backend sendingcamelCasekeys will silently produce empty attributes and relationships — no error, just missing data. This is the single most common integration bug; check it first when fields come back blank.
Trust and data boundaries
Each layer trusts exactly one neighbor. The Store trusts the adapter to return normalized data. The adapter trusts the serializer to coerce types and keys. Nothing outside the Store should trust itself to mutate record state.
Concretely: always change attributes through the tracked setter (record.set('title', x) in classic Ember, or tracked-property assignment in Octane with @tracked-backed models). Records returned by peekRecord or findRecord are live objects; assigning a property in a way that bypasses the framework's tracking means dirty state never updates, hasDirtyAttributes stays false, and save hooks don't fire.
IDs are server-authoritative. Either pass a known id to createRecord explicitly or let the server assign it on commit. Do not invent client-side ids and later "fix them up" — that breaks the identity map key and can strand relationships pointing at the old key.
Caching behavior you must opt out of deliberately
Ember Data 5.x leans cache-first: findRecord may resolve from the identity map without hitting the network, depending on your adapter's shouldBackgroundReloadRecord and the request options. For data where staleness matters (inventory, permissions, prices), be explicit:
// In a route's model() hook — runs in the browser, no special permissions
model({ post_id }) {
return this.store.findRecord('post', post_id, { reload: true });
}The risk of reload: true everywhere is request stampedes on hot routes. The risk of never reloading is users acting on stale state. Decide per model, not globally, and record the decision where the adapter is defined.
Operational checks
- Adapter traffic: enable
ENV.DS.LOGGER = trueinconfig/environment.jsduring development to log adapter/serializer activity. Requires an app restart; safe to leave on in dev only. - Record state in tests: assert on
isLoaded,isSaving, andhasDirtyAttributesrather than inferring state from UI. Run the built-in coverage withember test --filter='store'from the project root. - Existence before peek:
peekRecordreturns null for unknown records. Guard withstore.hasRecordForId('post', id)in code paths where presence isn't guaranteed. - Live arrays:
findAllresults update as records enter the Store. If a list grows unexpectedly, suspect a serializer pushing extra records via sideloading rather than a UI bug.
Failure modes and what they look like
| Failure | Symptom | Response |
|---|---|---|
| Server returns 422 | Record becomes invalid; record.errors populated | Render errors from record.errors; do not retry automatically |
| Network timeout | Record stuck in-flight; promise rejects | Let the user retry record.save(); record keeps its dirty attributes |
| Serializer key mismatch | Attributes/relationships silently empty | Compare raw payload in devtools against serializer expectations; add a keyForAttribute override |
| Duplicate id pushed twice | Dev-mode assertion; possible overwrite in prod | Find the second push source (often sideloaded data) and normalize it |
One subtlety: a snapshot's changedAttributes() compares against the last committed state, not the last save attempt. After a failed save and rollback, don't assume the snapshot has reset — verify with a test that exercises your exact rollback path.
When this design stops fitting
- GraphQL backend: the REST/JSON:API adapter split stops earning its keep. A single GraphQL adapter (or Apollo/urql with your own normalization) is simpler than forcing GraphQL responses through
JSONAPISerializer. - Offline-first: you need a local persistence layer and a conflict-resolution policy. That is a different architecture, not an adapter tweak.
- High-frequency pushes (websockets, live feeds): dirty tracking per record becomes overhead. Push raw payloads with
store.pushand notify changes manually, accepting that you give up automatic change tracking for those types.
The decision rule: keep the classic Store design while your backend is CRUD-shaped and your mutation rate is human-paced. Revisit it when either stops being true.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.