Blog post on "Piping Phoenix Contexts", has anyone tried this and can give their experience?

I came across this blog post: Piping Phoenix Contexts. It comes a time in a Phoenix app life… | by iacobson | Medium

I really like the idea and was wondering if anyone else in the community had thoughts and/or experience with this sort of implementation?

For context ( :smirk:) I have a Phoenix app with 10 contexts (each with variable numbers of models/schemas 2-10 each). I think the contexts are correct as it feels very intuitive and the terminology makes sense (at least for me) but I’m coming across issues where more and more features require multiple contexts to be called. Easiest example I think to understand would be the New User Onboarding requires:

Account.create_user
Organizaton.join
Analytics.track_create_user
Notifications.send_pending_membership_email
Notifications.send_admin_approval_email
+ a few more

I think this blog post describes a process that may make sense as the application grows but I’d like some feedback from people who have experience in this area.

Thank you

Depending in your final goals, you can check this options:

  1. The token approach: Advanced Techniques for Architecting Flow in Elixir · rrrene
  2. Ecto Multi: Ecto.Multi — Ecto v3.6.1

For the Ecto Multi option, you can wrap all those functions in a transaction Repo.transaction allowing you to handle a use case if one of the operations fail. Maybe rollback or take other actions.

Check the Multi.run function: Ecto.Multi — Ecto v3.6.1

I have used them both.

The specific pattern described there sounds like it would be better-handled with an Ecto.Multi; the data-shapes it uses are nearly identical (the four-tuple starting with :error, for instance).

The individual steps in that article’s pipeline also seem like prime candidates for unit-testing (if they weren’t defp): provide a state and verify what comes out, without having to do setup etc for every previous step.

In your case, the right answer is going to depend strongly on how your users think of the onboarding process:

  • is it “adding this new person to a specified organization”? The overall control flow might live inside Organization.join
  • is it “add this new person who happens to belong to this organization”? This is particularly likely if it’s possible for a user to exist without an organization. In that case, the overall control might live under Account
  • is it “create this user without an organization” and THEN (separately) add the user to an organization? Your Notifications function names suggest a user needs separate approval to exist (from admin) and to be a member (thus pending_membership_email). In that case, onboarding might be a separate whole context - particularly consider this if a user can retry onboarding or re-request approval.

For “notification”-ish things, another approach to consider is the Observer pattern. A simple implementation in Elixir would have the core data-creation code (Account.create_user for instance) notify all listeners (like Notifications) when a user is created:

defmodule SomeApp.EventBus do
  def deliver_event(event, payload) do
    config(event)
    |> Enum.each(&do_deliver_event(&1, event, payload))
  end

  defp do_deliver_event(subscriber, event, payload) do
    subscriber.deliver_event(event, payload)
  end

  defp config(event) do
    Application.get_env(:some_app, __MODULE__)
    |> Keyword.fetch(event, [])
  end
end

# in your config
config :some_app, SomeApp.EventBus,
  user_created: [SomeApp.Analytics, SomeApp.Notifications]

# in the contexts
defmodule SomeApp.Notifications do
  def deliver_event(:user_created, payload) do
    # do something useful
  end
end

defmodule SomeApp.Account do
  def create_user(...) do
    case do_real_work() do
      {:ok, result} ->
        SomeApp.EventBus.deliver_event(:user_created, result)
        # etc
    end
  end
end

There are LOTS of ways to improve on this basic structure - payload could be a struct, deliver_event could be part of a behaviour, deliver_event could be hardened to keep event-handler crashes from crashing the caller, you could get Kafka or something involved, etc etc etc