Todo: there are many different approaches to this, we should capture many of them here. The tl;dr: what do you do when you don't want to use ember-data, graphql, or popular conventions? (maybe your company has come up with its own conventions). Note: the future of ember-data (maybe even today?) should be flexible enough to account for many patterns

Simple ORM

A simple way to roll your own is the following pattern. We make a Model class and a ModelCollection class. They are given an Adapter class which knows how to use fetch() to talk to the API. A Route can simply use the Model or ModelCollection classes to return in its model() hook. This way the Model can manage the data anywhere it is used.

Adapter Class

Make one of these for each endpoint you need to interface with.

class MyModelAdapter {
  ajax() {
    return fetch(...arguments).then(r => r.json());
  }
  query(params) {
    return this.ajax('/api/models');
  }
  create(data) {
    return this.ajax('/api/models', { method: 'POST', data });
  }
  read(id) {
    return this.ajax(`/api/models/${id}`);
  }
  update(id, data) {
    return this.ajax(`/api/models/${id}`, { method: 'PATCH', data });
  }
  destroy(id) {
    return this.ajax(`/api/models/${id}`, { method: 'DELETE' });
  }
}

Model Class

You can use a generic class (shown below) or extend it for custom behavior.

class Model {
  constructor(data, adapter) {
    let { id, ...attrs } = data;
    this.adapter = adapter;
    this.data = attrs;
    this.id = id;
    this.isNew = !!this.id;
    this.isDirty = false;
  }
  setAttr(attr, value) {
    this.setAttrs({ [attr]: value });
    return this;
  }
  setAttrs(attrs) {
    this.data = { ...this.data, ...attrs };
    this.isDirty = true;
    return this;
  }
  importData({ id = this.id, ...attrs }) {
    this.setAttrs(attrs);
    this.id = id;
    this.isNew = false;
    this.isDirty = false;
    return this;
  }
  async reload() {
    let data = await this.adapter.read(this.id);
    return this.importData(data);
  }
  async save() {
    let data = this.isNew
      ? await this.adapter.create(this.data)
      : await this.adapter.update(this.id, this.data);
    return this.importData(data);
  }
  async destroy() {
    await this.adapter.destroy(this.id);
    this.isDestroyed = true;
    return this;
  }
}

ModelCollection Class

The above is for working with individual records while the ModelCollection class is for working with a collection of Model classes.

class ModelCollection {
  constructor(records) {
    this.records = records;
  }
  async create(data) {
    let model = new Model(data);
    await model.save();
    this.records.pushObject(model);
    return model;
  }
  async destroy(model) {
    await model.destroy();
    this.records.removeObject(model);
    return model;
  }
}

Usage (Route)

export default class MyRoute extends Route {
  async model(params) {
    let adapter = new MyModelAdapter();
    let results = await adapter.query(params);
    let records = results.map(data => new Model(data, adapter));
    let collection = new ModelCollection(records);
    collection.refreshRoute = () => this.refresh();
    return collection;
  }
}

Model / Relationship Loader

TODO: when loading data, the object you interact with is responsible for fetching and filtering all related data (this is similar to ember-data, if you only accessed things through async relationships)