Where to put "cross context preload"

I read different opinion and approach on the forum about preload of assoc, but I cannot decide how to handle “cross context preload”.

Let’s say I have the typical Post and Comment schemas in the Blog context, and each comment and posts has many Likes and the Like schema is in the SocialMedia context, with things like like_post(post) like_comment(comment)

I can have:

posts = Blog.list_posts() |> SocialMedia.load_likes()

but this will generate N+1 queries.

I can have:

posts = Blog.list_posts_with_likes() # or any other variation with arguments

Which preloads everything, but now this is “cross context”.

To add some example, let’s say I use this pattern:

There is is, in this pattern added queries, like:

  |> Accounts.filter_non_executive_users()

Now, where do I put the filter function “query compositor” if what I want to filter or preload lives in a different context?

Should I do:

Blog.list_posts(fn query ->
  query
  |> SocialMedia.preload_likes()
end)

And have preload_likes be “smart enough” to figure if query is on post or comment?

@kuon A preload will not generate N+1 query. It’ll generate a 1+1 query (one query for the data and one query for the preloaded date that Ecto will join). So if this is your only concern, I’d go for it.

In my example, list_posts return a list, so in load_likes I would have:

Enum.map(posts, fn p ->
  Repo.preload(p, :likes)
end)

This is why I prefer to put Repo.all in the controller, and have contexts return composable queries.

1 Like

When faced with these sorts of questions, I settled on only ever passing query functions that exist in the same context as the function that will eventually interact with the Repo.

Blog.list_posts(fn query ->
  query
  |> Blog.preload_post_likes()
end)

I’m not sure this is the right answer, but I did it for predictability, explicitness, and to protect against changes within the context. From outside the context, I don’t have to care if preload_post_likes directly specifies the query or if it delegates out to something in SocialMedia as that may change in the future.

2 Likes

Why not put Repo.all in the context? I feel this misses the point of contexts. A context should encapsulate your domain logic. Composing queries in a controller means the controller now understands concepts in your domain. Ideally, the controller would just call Blog.list_posts because that is all it cares about. Blog.list_posts and compose in the likes and fetch from the repo with the controller being none-the-wiser.

1 Like

It’s totally ok for contexts to talk to each other so long as it’s happening through the “aggregate root” (ie, the context file, even if this isn’t the 100% true definition).

Your Blog.list_posts_with_likes/0 is totally acceptable. So long as you are only referencing SocialMedia in the Blog context and not Like directly, you’re fine.

There are other ways to get totally clean separation but they are complete madness outside of a very complex project and I don’t want to go down that rabbit hole here. Your non-N+1 solution is correct!

I’m working on a project where we have modules like SchemaQuery where we put “shared” query logic to be consumed from other contexts. For your use case I’d do something like this:

posts.ex

import MyApp.Posts.PostQuery

def list_posts() do
  Post
  |> preload_likes()   
  |> Repo.all()
end

posts/post_query.ex

def preload_likes(queryable) do
  # do the preload logic here 
end
2 Likes

My problem is that in my case, I have “two” separate “aggregate root”, and you can talk to it through Blog and SocialMedia.

Those two root are very separate in domain, but need to preload “cross graph”, and I do not know where to put that “graph crossing” preload code.

I guess the approach of @baldwindavid here Where to put "cross context preload" - #6 by baldwindavid

is a simple answer.

“just cross the graph from the entity you are loading”.

But that means that the queried context has knowledge of the internal of the other contexts. But I guess it is a coupling we cannot avoid, as they are coupled in the DB anyway (foreign key…)

That’s incorrect. Repo.preload can receive a list, so you can just do Repo.preload(posts, :likes) and it will preload likes for all posts, with a single query. This is how we avoid n+1.

2 Likes

You are correct, but my example wasn’t reflecting the real situation. In my app, I have a place where I can either preload everything with a left lateral join, or afterward one by one, that’s why I used the Enum example.

In the end, I settled on something like:

Context.list_things(fn query ->
query
|> Context.filter_things_by()
|> Context.load_cross_context_assoc()
end)

This approach suggested by @baldwindavid is working well for the part I am “converting”.

It still seems to me that this could be a modeling problem. For instance, does it really make sense for your application to have separate contexts for Post and SocialMedia in the sense that likes are “completely” decoupled from posts? Remember that contexts are not exactly “bounded contexts” in the strict sense, even though they can certainly act like it if you want to - but in the end, there’s nothing preventing you from crossing those “concerns”, contexts are just modules that abstract shared logic.

Depending on the use case I don’t think I’d separate one thing from the other. But I’m making some assumptions here, could you share a little bit more about your project?

1 Like

I cannot share details about the project. But your point make sense. In the app, the different context are more here to “organize API” that to separate really different concern.

I was thinking about this more and came back to answer and @thiagomajesk said what I wanted to say and more.

Alternatively, if the SocialMedia context is necessary and you don’t want to cross contexts, you can always make a PostLike and/or a CommentLike in the Blog context that reads from the same source as SocialMedia.Like.

1 Like

It seems that our suggestions are very much aligned @sodapopcan. Oddly enough, I was about to suggest a similar solution to @kuon :grinning_face_with_smiling_eyes:

You could have something like a SocialPosts context that ties the concepts of both post and like working together. Both post and like could be a subset of the whole “post” and “like” schemas you already have.

Something like: social_posts/post and social_posts/like would abstract the domain “relationship” between posts that have social media requirements embedded into it.

It’s also important to remember that we can have multiple schemas representing distinct domain facets (loosely speaking). You are also not required to have schemas backed by a database (they could work similarly to value objects in DDD if necessary).

1 Like