How to structure a large live view app?

Hey everyone,

I have a live view app that is slowly getting bigger and I’m really struggling to find a way to manage all of the logic for each page. Components, queries, page, etc.

I was hoping to get some insight on how I can best have file structure and other tips on making sure I can manage all the code for these things. Right now I feel that the contexts are getting in the way and I should be moving the queries in a file next to the page view itself.

Any insight and resources would be much appreciated!

12 Likes

I put components relevant to only the one page in a folder with or under the page. If there is a lot of web domain logic, create a AppWeb.Whatever that houses all of that. The extra queries I leave over in App.Whatever or create a module under that or a new one if there is a lot of logic for a subset of the context.

I feel like putting your queries in the web domain is crossing a line that a lot of people (but not all) are uncomfortable with.

1 Like

Thanks for responding! Is there a clearer example you can give me?

For example: I have a page MyAppWeb.Admins.Locations.Events.Index that has a list of events that you can:

  • filter by location
  • filter by category
  • move to the next month
  • move to the previous month
  • show/hide the events that already have occured this month
  • filter by the person who created the event

I have quite a few pages like this and its starting to feel a little overwhelming.

The more that I add here the less it feels that the query is belonging on the Web domain side and that background job logic belong on the MyApp side.

They feel too far away from each other at this point both because it’s in a 2x nested resource and it’s a lot of logic to handle.

Therefore I’m thinking MyAppWeb.Admins.Locations.Events.Index.Queries feels a bit better but I’m not sure if this the way to go.

Instead of passing queries from web… can You pass criteria to the queries? I use the liveview to manage parameters I send to the query

I have one list_events function that do this only by passing parameters to the query. Do You have multiple queries instead?

4 Likes

Thanks for also responding!

So you’re suggesting have a large query struct and then add conditionals based on what is in the query struct? Thus basically a god query for Events?

I am suggesting a technique I learned from the book Absinthe/GraphQL…

For example…

  def list_requests_query(criteria \\ []) do
    query = from(p in Request)

    Enum.reduce(criteria, query, fn
      {:limit, limit}, query ->
        from p in query, limit: ^limit

      {:offset, offset}, query ->
        from p in query, offset: ^offset

      {:filter, filters}, query ->
        filter_with(filters, query)

      {:order, order}, query ->
        from p in query, order_by: [{^order, ^@order_field}]

      {:preload, preloads}, query ->
        from p in query, preload: ^preloads

      arg, query ->
        Logger.info("args is not matched in query #{inspect(arg)}")
        query
    end)
  end

  defp filter_with(filters, query) do
    Enum.reduce(filters, query, fn
      {:user_id, user_id}, query ->
        from q in query, where: q.user_id == ^user_id

      {:is_fetched, is_fetched}, query ->
        from q in query, where: q.is_fetched == ^is_fetched

      {:is_post_processed, is_post_processed}, query ->
        from q in query, where: q.is_post_processed == ^is_post_processed

      {:with_medium_path, true}, query ->
        from q in query, where: not is_nil(q.medium_path)

      {:with_medium_path, false}, query ->
        from q in query, where: is_nil(q.medium_path)

      arg, query ->
        Logger.info("args is not matched in query #{inspect(arg)}")
        query
    end)
  end

  def list_requests(criteria \\ []) do
    criteria
    |> list_requests_query()
    |> Repo.all()
  end

Using this, I could probably do something like…

        filter by location
        filter by category
        move to the next month
        move to the previous month
        show/hide the events that already have occured this month
        filter by the person who created the event

… and this would translate to

[filter: [by_location: location, by_category: category, by_month: month ...]]
|> App.list_requests()
17 Likes

Wow this is beautiful! Thank you for this. I think this is exactly what I need

Sometimes it’s nice to have something a bit more high level than passing a keyword list of ecto query options, which can be in addition to or instead of the more low level method in the post above. Maybe you specify options filter: filter, show_already_occured: true, after: last_event_on_page in the web context and convert that to the lower level options in your app domain.

If you feel there is too much query logic in the Events module, you can run a separate EventQuery module with composable queries and functions in your Events module will pipe through them. For example, you can have a EventQuery.with_filter function that takes your filter (that is serialisable to URL query params) and turns it into ecto query options. In doing that, you probably don’t need to split Events.Index and can flatten into Events

1 Like

Is it possible to follow a vertical-slice architecture in Phoenix or LiveView, to organize code?

Phoenix doesn’t impose any major constraints on how you organise your files.

1 Like

I’m (much) behind you on this journey but here are a few things things I’ve found helpful:

  1. Elixir really doesn’t care where in your modules are located in your project, so I haven’t been shy about grouping things that I think belong together in their own subfolder, or moving things around.
  2. I occasionally reorganize the “namespaces” (they’re not really namespaces, but I mean the names in between dots of a full module name) and move functions around between modules. It seems to be much easier in Elixir than in an OO language/system, and Elixir language server does a truly great job telling you what needs fixing. I hope that I’ll need to do less of this as I get more experienced with Phoenix, but it’s nice not having to make the right calls the first time.
  3. I haven’t used defdelegate much yet, but that seems like a great way to isolate code that takes on a life of its own and only expose what you need in your main api modules.
  4. Adopting Elixir by Ben Marx, José Valim, and Bruce Tate is a great book that seems like it was written to answer your exact question!
1 Like

I think this is the thing I’m looking for when it comes to the querying which will be one step closer to maintaining the chaos I’ve sewn. Thanks y’all!

I usually try to organize my code like this

- lib
-- project
---- orders
------ order.ex # schema
---- orders.ex # context
-- project_web
---- components # for shared/general components
------ header.ex
---- live
------ orders_live
-------- orders_live.ex # LV module handle in/out for info where heavy biz logic lives inside the context
-------- orders_live.html.heex
-------- component_related_to_orders_feature.ex

If that helps you.

If my context starts to grow I can extract into a smaller new module to handle queries, but I don’t do this that often.

3 Likes

Thanks for responding everyone. The reducer function idea from kokolegorille is exactly what I’m looking for. That will clean up A LOT of my logic.

I’m curious how you name your

lib/project_web/live/orders_live/orders_live.ex module, is it one of:

  • ProjectWeb.Live.OrdersLive
  • ProjectWeb.OrdersLive
  • ProjectWeb.Live.OrdersLive.OrdersLive (probably not, but this is the “strict” file name to module name version)

I’ve been waffling between them, especially as the module hierarchy gets deeper, e.g for a module like lib/project_web/live/orders_live/order_form_live.ex that “belongs” in the orders_live/ directory.

This blog series by Sasǎ Jurić was really insightful and I’ve adopted some of the practices in my projects

2 Likes

I like it flat, irrespective of being in the live and orders_live folders.

1 Like

Yes, we usually go with ProjectWeb.OrdersLive, even if that lives in the project_web/live/orders_live directory.

1 Like

My preference is Web.Live.Orders and web/live/orders.ex (note: not ProjectWeb, just Web; the prefix always felt superfluous and after a bunch of projects without it, I don’t miss it at all).

2 Likes

Maybe I’m a heretic, but I’ve stopped using “Live” in any module (or folder) naming - most things are live now so it seems superfluous. Any non-live UI modules are controllers and/or views and named explicitly.

In the example above, I’d go:

lib/project_web/orders
  index.ex - ProjectWeb.Orders.Index
  view.ex - ProjectWeb.Orders.View
  edit.ex - ProjectWeb.Orders.Edit
  line_item_view_component.ex - ProjectWeb.Orders.LineItemView
  line_item_edit_form.ex - ProjectWeb.Orders.LineItemEdit

Any common logic related to the orders front end would get put in a module in that folder too.

Grouping everything around end user functionality makes it easier (IMO) to navigate between use wants, issues and bugs and the codebase.

Shared/generic components (buttons, progress bars, form fields etc) live in a components area at the top of the tree. Generally I put live components in a module to themselves, and function components grouped by general area of functionality.

lib/project_web/components
  forms.ex
  nav.ex
  datagrid.ex
  ...

As with most things, the biggest project I’m working on has archeology rather than architecture - there’s a few iterations of thinking about where to put things and not everything is in the right spot as yet.

4 Likes