What are the benefits of async relationship?

This question was already asked once but there was no satisfying response. Let’s try to answer this question again in 2019.

I’ve found this article arguing against using async relationships and to be honest, it makes sense. Accessing model property and as a side effect triggering server request is not what a new developer would expect. It adds more complexity and on top of that, we’re losing power to control how and when the requests are made.

So why is it that ember-data has by default async relationships? What are the benefits of that over sync relationships?

2 Likes

One reason it’s the default is simply because it has been the default—someone decided that’s how it would be at some point a long time ago, and inertia kept it that way. Changing it requires a major version/breaking change release. If you take a look at some of the big picture items outlined in @runspired’s #EmberJS2019 blog post, you’ll note that there are a bunch of changes inbound for Ember Data, though, and I’d venture that the Ember Data Core Team would be happy to work through an RFC arguing to flip the default!

I can provide a little of the original rationale for async relationships.

When you’re just starting out, they’re really convenient. They let you traverse the graph of models lazily, on-demand and not need to stop and worry about it. They’re particularly good if all your data access tends to happen in templates, because templates are declarative and can cleanly deal with any asynchrony without you needing to worry about it. While you’re on that happy path, async relationships take what would have been an exception and make it Just Work. That’s nice and it means one less thing for a newbie to deal with at that moment in time. It means you can first spend effort getting your UI right, and later when you’re ready spend effort reducing the number of network requests you can do that separately (and maybe you’ll never even bother with step 2, based on the requirements of your app).

But I still agree with pretty much everything in the article. As soon as people go beyond the basic cases, there’s a cliff you step off and the asynchrony is more painful than helpful. I think people hit the cliff early enough that it’s not worth it. Unfortunately that does mean pushing more explicit work onto users earlier in the process.

4 Likes

I will just add that Rails faced similar footguns with ActiveRecord, which influenced the design of Ecto (Phoenix’s data layer) to throw compile-time errors if a template ever tried to render data that wasn’t explicitly sideloaded by the programmer.

When I learned this it had a big impact on my opinion of the problem space, because it was basically an admission that data loading is too complex (at this stage of the game) to be delegated to an automated tooling layer.

4 Likes

Thanks for sharing your thoughts.

Personally, I think Ember defaults should be set in a way that the app can grow big and still be a maintainable piece of software. The “easy for newbies” part should be “nice to have” in most cases.

I’m not sure if ember-data can recover from this, or at least it won’t be easy. In our case, we have close to 300k+ lines of JS code and so far I couldn’t think of any way to go from async to sync relationships in a safe manner, without introducing bugs as a side effect of that. And I think this will be an issue with any larger app out there. If ember-data changes this default in the next major version, we probably won’t be able to upgrade unless there will be a nice and safe upgrade path.

That’s precisely why it remains the way it is today: that’s been true for a lot of apps for a long time. I’m hopeful that the work the Ember Data team is aiming to do over the next many months will unlock not just better patterns but better patterns with a migration story.

FWIW There is a plan to move from async to sync with a nice migration path, and it’s much easier than you might think.

It starts with understanding that the data structure we return from both sync and async relationship access is too flat. Relationships need information about meta/errors/links beyond just the records that they contain. Today this information is difficult to access generally and especially hard to access within a template.

Were we to introduce a better primitive that was slightly less flat we could use the opportunity to also change how you access that data structure both in your JS code and in your templates.

For example, imagine relationships had the following interface for their resolved data

interface Relationship {
   data: Identifier|Identifier[]|null;
   links?: Links;
   errors?: Error[];
   meta?: object 
}

and to access the records for a relationship in your JS

let data = await fetchRelationshipData(record.myRelationship);

and to access those same records in your template with an async boundary in the template

{{#let (fetch-relationship-data record.myRelationship) as |list|}}
  {{#each list as |item|}}
      ...
  {{/each}}
{{/let}}

Or to use the available errors

{{#each record.myRelationship.errors as |error|}}

{{/each}}

Or to just output a count or some other meta info

Showing 1 - {{record.myRelationship.data.length}}
of  {{record.myRelationship.meta.total}}. 

<button onclick={{loadLink record.myRelationship.links.next}}>
  Load More
</button>

You would opt into this by changing from belongsTo and hasMany to resource and collection decorators on your classes (or by using a custom record class).

This would allow for incremental migration, and the functional patterns would be made to work with both to make that even easier.

3 Likes

But doesn’t that still require from developer to back-trace all the places where the relationship is accessed/used and make sure that on those places is the relationship loaded manually?

I was actually thinking about incremental adoption by using {async: false} option on hasMany/belongsTo, but because of the reason above, it woudn’t be safe for us.

@ondrejsevcik You are quite right that migrating directly to a pattern with enforced fetch presents inherit risk, but it’s the same risk that exists during initial development (e.g. enforced peek vs allowed fetch is a similar issue either way).

What the pattern I’m showing does allow for is a migration in which you first keep the existing behavior but with a new pattern and then begin phasing out the locations in which you want to enforce no-fetch. You could write helpers to use this pattern with ember-data-storefront as well to ease migrations there.

So for an existing async relationship:

{{#each record.myRelationship as |item|}}

{{/each}}

Becomes

{{#let (fetch-relationship-data record.myRelationship) as |list|}}
  {{#each list as |item|}}
      ...
  {{/each}}
{{/let}}

and then if you don’t want to allow the fetch

{{#let (peek-relationship-data record.myRelationship) as |list|}}
  {{#each list as |item|}}
      ...
  {{/each}}
{{/let}}

here, peek would error if the data were not available locally

Thanks for pushing ember-data forward!