In most of the mature apps we assess, the oldest code is not a component. It is the data layer. Adapters and serializers written against Ember Data 1.x or 2.x conventions, a RESTAdapter subclass with a decade of urlForQuery special cases, a serializer that normalizes an endpoint nobody has changed since 2016. The templates got converted to Octane. The data layer did not.
That matters now because Ember Data's own architecture moved. Recent versions add a request layer — a RequestManager with handlers, plus request builders — that sits in front of the cache, and the newer schema-driven record work is being developed under the WarpDrive name. The adapter/serializer pattern still runs; it is implemented as a legacy handler on top of the same machinery. That detail is the whole opportunity: the new layer and the old one coexist in one app, which means this is an incremental job rather than a data-layer rewrite.
Here is the order we work in.
First, find out what your data layer actually does
Before touching anything, read the adapters and serializers and write down the behaviour they encode. In a long-lived app that list is usually longer than anyone expects:
- URL shapes that don't follow the convention, per model.
- Header and auth logic, sometimes duplicated between an adapter and a service.
- Retry, error normalization, and the one endpoint that returns 200 with an error body.
- Payload reshaping: sideloaded relationships, renamed keys, embedded records, a field that is a JSON string on the wire and an object in the app.
- Coalescing and caching behaviour that screens quietly depend on for performance.
This document is the real deliverable of the first week. Every later decision refers back to it, and it is worth having even if you decide to modernize nothing.
Get current on the version you are on
Don't adopt new APIs from three versions ahead of where you sit. Land the ordinary upgrade work first: get Ember Data onto a current release line for your Ember LTS, clear its deprecations with the deprecation flags in package.json rather than by guessing, and make sure the test suite is meaningful before you change how requests are made. The request layer is only available to you once you are on a version that ships it, and deprecation clearing is where most of the surprises in this project actually live.
Introduce the request manager as plumbing, not as a rewrite
The first real step is small: install a RequestManager, give it a fetch handler, and put the legacy handler behind it so existing store.findRecord and store.query calls keep routing through adapters exactly as before. Nothing about your models changes. What you gain is a single place where cross-cutting request concerns belong.
Then move those concerns into handlers, one at a time:
- Auth headers and token refresh.
- Retry and timeout policy.
- Request logging and correlation IDs.
- Error normalization, so screens stop each parsing errors their own way.
Each handler you add usually deletes code from an adapter subclass or a wrapper service. This stage alone often justifies the work, because it replaces scattered request behaviour with something a new engineer can read in one file.
Adopt builders on new work first
With the manager in place, new endpoints can be written in the newer style — store.request with a builder, returning the response you asked for — while every existing screen continues on adapters. Start with the code your team is writing this quarter anyway. It gives the team the idiom on low-risk surface area, and it does not put a migration dependency in front of the roadmap.
When you do start converting existing calls, sequence them the same way we sequence any incremental migration: by route, lowest churn first, one model or one endpoint family at a time. A model whose adapter is conventional and whose serializer does nothing interesting converts in an afternoon. The model with 300 lines of serializer is a project; schedule it as one.
Expect the relationship and cache questions to be the hard part
The request-level work is mostly mechanical. What takes judgement is anything that leans on the store's implicit behaviour: relationships loaded as a side effect of some other request, code that reads a partially loaded record and relies on it filling in later, screens whose performance comes from coalescing you did not know was on. Those are the places to add tests before you change anything, and to parallel-run — old path and new path behind a flag, compared on real traffic — rather than reason about.
The same applies to the schema-driven record direction. It is a genuinely different model of what a record is, and it is worth understanding before you commit; it is not something to adopt across a large app in one pass while the API is still settling. Read it, try it on one bounded model, and let the app tell you.
When to leave it alone
Sometimes the honest recommendation is to stop after the audit. A data layer that is ugly but stable, wrapping an API nobody is changing, in an app with two more years of expected life, is not where your engineering budget does the most good. If the pain you were sent to fix is upgrade friction, slow builds, or hiring, fix that instead — those have clearer returns.
We would push for the request layer specifically when one of these is true: request concerns are duplicated across adapters and services and it is causing bugs; you need retry, auth, or caching policy you cannot express in the adapter API; the API itself is being reshaped and you want new endpoints written in the current idiom; or you are planning a migration off Ember and want the request and error behaviour extracted into plain TypeScript that a React or Svelte app can call. That last one is the quiet argument for doing this work: handlers are ordinary code, and ordinary code moves.
The order, in short
- Document what the adapters and serializers really do.
- Upgrade Ember Data and clear deprecations on your current LTS.
- Add a
RequestManagerwith the legacy handler behind it. Nothing changes for callers. - Move auth, retry, logging, and error normalization into handlers.
- Write new endpoints with builders and
store.request. - Convert old calls by route, low churn first, with tests and a parallel run on anything relationship-heavy.
- Stop when the remaining cost stops buying anything.
None of this needs a data-layer freeze, and none of it needs a branch that lives for a quarter. The app stays deployable the whole way, which is the only condition under which this kind of work actually finishes.