How to split a router into multiple modules using Phoenix

hassan shaikley
Hassan’s Tech Blog
3 min readMay 13, 2021

--

This is a common question when using Phoenix and when searching the web I didn’t find any complete examples. I ended up experimenting for myself and I figured I might as well share my findings.

You essentially have two options.

  1. Because a router is a plug, you can forward/4 to another router. However it does some things you might not expect. The path_info in the conn gets changed. In the example below /one is removed from path_info. We’ll go into more detail on that below! There’s another issue: according to the Phoenix docs, you may encounter a bug where plugs defined by your app and the forwarded endpoints get invoked twice.

Note: The path_info is the url after the TLD split by slashes, so for a url example.com/user/1/profile the path info would be [“user”, “1”, “profile"].

For the sake of learning I’ll demonstrate how to split a router using forward. Below I demonstrate making these changes to the router of a freshly generated a phoenix application:

scope "/", AppWeb do
pipe_through :browser
get "/", PageController, :index
forward “/one”, RouterOne
end

And then create the new router module like this:

defmodule AppWeb.RouterOne do
use Phoenix.Router
get “/”, AppWeb.ControllerOne, :index
end

Now http://localhost:4000/one takes you to that route. Assuming you’re using the port 4000…and that you’re capable of running your app locally.

2. The often preferable solution is to match/5 within the router. This doesn’t appear to have any side effects. It merely directs the request to the other router. It has the added benefit of being able to use the first parameter to match some HTTP verbs, for instance get, to a specific router.

Replace forward with:

match(:*, “/one/”, RouterOne, [])

Note: The first :* parameter means match all HTTP methods.

Change RouterOne a tiny bit to accommodate:

get “/”, AppWeb.ControllerOne, :index

get “/one”, AppWeb.ControllerOne, :index

Voila! You have split out your router with match.

If you inspect the difference in the conn between both approaches you’ll notice 4 differences: (at least using Phoenix version 1.5.8).

The conn contains a lot of data but it’s this specifically that differs from match when you use forward:

path_info: [],
script_name: ["one"],
private: %{
AppWeb.Router => {[], %{AppWeb.RouterOne => [“one”]}},
AppWeb.RouterOne => {[“one”], %{}},

And if you use match it looks something like this:

path_info: [“one”],
script_name: [],
private: %{
AppWeb.Router => {[], %{}},
AppWeb.RouterOne => {[], %{}},

Match preserves path_info and forward moves it into private in a way that’s hard to use and make sense of which is in my opinion another downside.

There’s also a script_name key that gets introduced. It is the initial portion of a URLs path that corresponds to the application routing.

If you look at the source code for match not too much is happening.

defmacro match(verb, path, plug, plug_opts, options \\ []) do
add_route(:match, verb, path, plug, plug_opts, options)
end

However when you look at forward there’s a lot more going on.

defmacro forward(path, plug, plug_opts \\ [], router_opts \\ []) do
plug = Macro.expand(plug, %{__CALLER__ | function: {:init, 1}})
router_opts = Keyword.put(router_opts, :as, nil)
quote unquote: true, bind_quoted: [path: path, plug: plug] do
plug = Scope.register_forwards(__MODULE__, path, plug)
unquote(add_route(:forward, :*, path, plug, plug_opts, router_opts))
end
end

You can see that the router_opts get changed. But there’s a lot more to it. But wait, there’s more! But it’s outside of the scope of this post. tl;dr: there is much more to forward than one might assume.

If you aren’t convinced you should be using match yet, forward also manages to do something quite confusing. If you run mix phx.routes and you’re using forward you’ll see:

* /one PhoenixSplitRouteExampleWeb.RouterOne []

As opposed to match which very similarly to non forwarded routes, outputs:

router_one_path * /one PhoenixSplitRouteExampleWeb.RouterOne []

Which is actually maintainable. We don’t want to muck the output of mix phx.routes. I believe it is safe to say that in most cases we want to use match/5.

I hope you enjoyed this! If you’re interested I have a link to a pull request that demonstrates this code below.

Visit https://engineering.community.com/ for more great content by engineers at Community.

--

--