Ember Data has strong opinions on how it wants you to structure your data and your API, which are essentially collapsed into one by its default paradigm. If you are using ActiveModelSerializer
, the path of least resistance is to have your DS.Model
classes essentially mirror your ActiveRecord classes, to the point where I feel like an Ember Data app is often doing SQL over AJAX.
I’m not too fond of this pattern. In all but the simplest apps, you generally want some divergence between your client side models and your database tables. On the flip side, you definitely want to avoid making AJAX calls to arbitrary end points with no overall API design.
A technique I’ve used to find a happy medium for this problem is to center your API around transferring the state of resources (i.e. REST), using only the standard CRUD operations whenever possible. In my experience, thinking of client-server communication as resource sharing tends to provide some very helpful guard rails, while giving you the freedom to diverge from mirroring your database tables in your client models. Here are a few examples of how I’m using that freedom in my current project.
The Most Recent Feedback
Let’s say you are building an app that lets users leave feedback on items they purchase. If a user later wants to change their feedback, they should be allowed to. This could be modeled as a simple “has one” relationship: Item -> Feedback
. However, to retain the most possible historical information, your company wants to track all previous feedback the user has provided for that item. That means using a “has many” relationship: Item -> Feedback[]
. However you don’t need to show those earlier feedback submissions to the user, only the most recent.
There are several ways you could implement this, but one is by having the API / Ember Data consider the relationship as a “has one.” Essentially you are talking about a different type of resource: on the server you have Feedback
, but on the client you have MostRecentFeedback
. When you “update” a MostRecentFeedback
, the server may create a new Feedback
, but the MostRecentFeedback
“resource” remains the same. The ID of a MostRecentFeedback
would not be the ID of a particular Feedback
record, but basically the Item
ID instead. The Item
ID provides all the information the server needs to find the MostRecentFeedback
.
Sideload All the Things
When our app starts up, there are a number of model types for which we want to load all the records. Think of things like enumerated values or categories. Rather than ask for all of them individually, we ask for all the GlobalData
records (GET /global-data
). The funny thing is, we don’t even return any GlobalData
records, we only sideload the records we want.
{
global-data: [], //nothing
categories: [
{ id: 1, /* ... */ },
{ id: 2, /* ... */ },
/* ... */
{ id: 32, /* ... */ },
],
rating-levels: [ /* ... */ ],
/* ... */
settings: [ /* ... */ ]
}
Bulking Up
Sometimes you have a one-to-many relationship where one user action affects multiple models. For example, if you maintain an ordering field and you reorder the records. Or if you “publish” the “one” parent model and it updates all the “many” children.
Rather than send down a boatload of PUT requests for every child, we set a field on the parent model that correlates to the state change we wish to make, and then save only the parent model, and then use the sideloaded child models in the response to update those models as well.
Session is as Session does
Perhaps my favorite unique use of “resources” in our API is the way we handle sessions. Essentially, the client presumes it should perform operations on a specific UserSession
model (id 1, although that is arbitrary). The UserSession
model has one User
model. When the UserSession
is .save()
‘d, if it contains an email and a password, the server will authenticate and then return the User
association filled in. In short:
UserSession
- create: authenticate and return
UserSession
with logged inUser
- read: gets the current
UserSession
, withUser
if logged in - update: re-authenticate and return
UserSession
with logged inUser
- destroy: logs out the current
User
and erases session data
I like this approach because it matches semantically with the state of session data for a user. It may or may not correlate to any data in a database, but the user session data can still be thought of as a CRUD’able resource.
Coding at the Improv
Ember Data provides some fairly sensible defaults for structuring your API, but like any framework sometimes you need to get creative. As a general rule, I try to understand the intent of the framework’s authors before going my own way. It might be they had the same problem I’m staring at now, and already provided an integrated mechanism for dealing with it. Using a framework—and programming in general—is like jazz: you have to know the rules before you can break them. But you’ll never be great without breaking some rules.
Nice post, I agree with being creative when building APIs and the importance of viewing at them as a resource service and not necessarily as a concrete rails model/DB table. Sticking to CRUD as a default is a great recommendation and any time someone wants to expand a resource to be something more than a standard CRUD interface should run their thought across another experienced dev for a second opinion. Not that you were saying otherwise, but I do think ember-data is written in mind to allow its users to get creative with their APIs and I am glad that you are encouraging other developers to think about it from a more broad perspective.
I too use Active Model Serializers in Rails projects along with Pundit (for authorization) and SearchObject (for filtering) for APIs. Currently, we have not yet switched over to the JSON API endpoint yet but at some point will consider doing so.
I also use AMS and ember-data but I tend to like to bring AR queries to the ember level. And I have built a gem for that. Is it a good case? It depends. If your project is a small rails/ember then it’s ok and saves you time from building complex APIs but if your project is a large one then you should have predefined API filters/attributes and optimize them.
Your definition on user sessions is a bit confusing for beginners but the most correct to the REST principles.