LUX
Dive into modern @ember-data - Part 1

At EmberFest 2019 runspired, one of the main developers of @ember-data, gave an amazing talk about the future of @ember-data. The vision completely fascinated me, and I had some amazing conversations with runspired after. Over the years, we had a few more very interesting conversations, and recently we had a longer chat about all of this, and what is now already implemented in @ember-data as public API and ready to use.

Now this will (hopefully) be a little series about the future of @ember-data that we already have in parts, and how to use it, from the perspective of someone that didn't develop it.

As ember-data users we currently mostly write instances of @ember-data/model, use the methods findAll, findRecord, query, queryRecord, peek and peekRecord on the Store Service. Now while the methods on the Store are usually nice to use, @ember-data/model feels in many ways a bit outdated, and especially relationships often can feel clumsy to use in modern ember. We maybe also sometimes write a Serializer or Adapter, or use the default ones, but that's very often a once-per-project kind of work.

Over the last years ember-data has been split into multiple packages under the @ember-data namespace, while ember-data just glues these together. The center of all of this is @ember-data/store. Then @ember-data/record-data is where the actual data is stored. This is currently build for JSON:API style of data, however, I know the @ember-data Team has plans for things like GraphQL.

Now our goal in this series is to completely replace @ember-data/model with something custom.

The Schema and the Model

We currently write Model classes for two reasons:

  1. We declare the Schema of our data here. Namely which attributes we have, which transforms we use (like @attr('date')), and which relationships we have.
  2. We use this classes as actual instances of our data. We can define computed properties on them, methods, and generally just pass them around.

To allow other approaches for models and APIs, @ember-data/store, the core of everything, now seperates these two things. @ember-data/model currently provides both. On our road to replace @ember-data/model with something custom, we will also need to replace both. While I first wanted to only replace the Schema, and later the presentation classes, this seems not possible.

Starting the project

We can start with a plain new ember project, remove ember-data from the dependencies, but instead use:

"@ember-data/record-data": "4.8.0-beta.0",
"@ember-data/store": "4.8.0-beta.0",

We did not add @ember-data/model, since this is precisely what we want to build on our own.

We also will not use @ember-data/adapter or @ember-data/serializer at all. Serializers, as a concept, are a bit a strange separation from the adapter anyway. While the customization of the standard adapter and serializer can be very nice, when implementing both from scratch, I find myself often wondering why I can't just directly return the JSON:API data from the adapter. Because of this, serializers are completely optional in recent @ember-data. And while we do need to write an adapter, we just need to implement the minimal interface, and no longer need to extend from a special base class.

A trivial implementation

The first thing of all is that we need to provide our own extension of the Store. We can start with something very trivial:

import BaseStore from '@ember-data/store';

export default class Store extends BaseStore {
}

Now we slowly will need to add capabilities to our Store. We will need to add an adapter, a schema, and some kind of models.

Lets start with a little adapter, to actually give us some data. We will just return static data for now, so something like this will do:

class Adapter {
  async query() {
    const data = [
      {
        id: 'one',
        type: 'movie',
        attributes: {
          name: 'Matrix',
          director: 'Wachowski',
          releaseDate: '1999-03-24',
        },
      },
      {
        id: 'two',
        type: 'movie',
        attributes: {
          name: 'Avatar',
          genre: 'SciFi',
        },
      },
    ];

    return { data };
  }
}

This will not be used or imported in any magical way. We just overwrite adapterFor in our Store to return the adapter:

export default class Store extends BaseStore {
  adapter = new Adapter();
  adapterFor() {
    return this.adapter;
  }
}

The next thing we need is a Schema. We can also start with something trivial:

class Schema {
  attributes = new Map();
  doesTypeExist() {
    return true;
  }

  attributesDefinitionFor({ id, type }) {
    if (type === 'movie') {
      return {
        name: { name: 'name'},
        director: { name: 'director'},
        releaseDate: { name: 'releaseDate'},
        genre: { name: 'genre'},
      };
    }
    return {};
  }

  relationshipsDefinitionFor({ id, type }) {
    return {};
  }
}

We need to register our Schema in the Store as well. This can be done in the constructor:

constructor() {
  super(...arguments);
  this.registerSchemaDefinitionService(new Schema());
}

These were all preconditions we need to provide our own Model class. For this we will need to implement instantiateRecord in our Store. Lets first have a look at its signature:

instantiateRecord(
  identifier: StableRecordIdentifier,
  createRecordArgs: { [key: string]: unknown },
  recordDataFor: (identifier: StableRecordIdentifier) => RecordData,
  notificationManager: NotificationManager
): DSModel | RecordInstance

The important pieces for now are the identifier, which has a type property, and recordDataFor, which will allow us to actually access the data stored in @ember-data/record-data. As mentioned in the beginning, this is the place where all data is stored. Our Model now needs to implement getters and setters for all attributes, to proxy the data to RecordData. The data in RecordData is always stored in its serialized form. So for things like Dates, we need to provide a getter/setter pair, that can transform it on the fly. This underlines the role of the Model as a presentation layer of the data, so it is nice to use for our application.

We can now write a simple Model class:

class Movie {
  constructor(identifier, recordDataFor) {
    this.identifier = identifier;
    this.recordDataFor = recordDataFor;
  }

  get name() {
    const recordData = this.recordDataFor(this.identifier);
    return recordData.getAttr(this.identifier, 'name');
  }
  set name(value) {
    const recordData = this.recordDataFor(this.identifier);
    recordData.setAttr(this.identifier, 'name', value);
  }

  get director() {
    const recordData = this.recordDataFor(this.identifier);
    return recordData.getAttr(this.identifier, 'director');
  }
  set director(value) {
    const recordData = this.recordDataFor(this.identifier);
    recordData.setAttr(this.identifier, 'director', value);
  }

  get releaseDate() {
    const recordData = this.recordDataFor(this.identifier);
    return recordData.getAttr(this.identifier, 'releaseDate');
  }
  set releaseDate(value) {
    const recordData = this.recordDataFor(this.identifier);
    recordData.setAttr(this.identifier, 'releaseDate', value);
  }

  get genre() {
    const recordData = this.recordDataFor(this.identifier);
    return recordData.getAttr(this.identifier, 'genre');
  }
  set genre(value) {
    const recordData = this.recordDataFor(this.identifier);
    recordData.setAttr(this.identifier, 'genre', value);
  }
}

Writing manual getters and setters for all attributes is very cumbersome, and something we will address in the next steps. However, it's important to note that the Model does not actually store anything, but proxies all data to RecordData.

We can now use our Model class in the Store:

instantiateRecord(
  identifier,
  createRecordArgs,
  recordDataFor,
  notificationManager
) {
  if(identifier.type === 'movie') {
    return new Movie(identifier, recordDataFor);
  }
}

Now we can just do this.store.query('movie', {}) to retrieve the data.

Outlook

While we have a nice little demo so far, there is much to do and explore. In future blog posts we will look into these topics. We will see how we can avoid the cumbersome getters in the model class, and how we can automatically infer the Schema from the data when using JSON:API. We also did not yet look into relationships at all, which is something we definitely will need to do.