Hi all,
I currently have this thought that relying solely on modules in Elixir hinders the writing of polymorphic code. Let me explain my thought process with an example.
Let’s imagine a shop project, using phoenix and ecto, and let’s roughly implement a route that creates a purchasable product. This has to follow some rules defined by the Sales and Strategy teams. For example, the name must be unique, there must be a sell by date no later than 3 months after creation, it has to be approved by the CEO before customers can purchase it, etc.
In the router:
post "/purchasables", PurchasablesController, :create
In the controller
def create(conn, params) do
purchasable_creation_request = %PurchasableCreationRequest{
name: params.name,
approved: false,
created_at: Timex.now()}
case BusinessRules.Purchasables.create(purchasable_creation_request, PurchasablesGateway) do
{:ok, purchasable} ->
conn
|> put_flash(:info, "Success")
|> assign(purchasable, purchasable)
|> render(:show)
{:error, reason} ->
conn
|> put_flash(:error, "Creation failed because #{reason}")
|> render(:create)
end
end
Here we have the controller convert the http request into something the business rule can work with. The dependency and flow of control only go in one direction. While we have successfully split all the code into 2 modules, they are still tightly coupled.
If I look at the source code dependencies and the flow of control, they both go in the same direction:
- The PurchasablesController uses BusinessRules.Purchsables.create/2
- The PurchasablesController depends on BusinessRules.Purchsables.create/2
Concrete things use and depend on concrete things.
At the same time, it looks like BusinessRules.Purchasables.create/2
uses a gateway for purchasables but does not depend on a concrete one.
In a polymorphic setup, we would often observe that. The flow of control opposes the source code dependencies: it’s not because ModuleA uses Ecto that it has to depend on Ecto.
Interestingly this is very palpable in tests. Howvever, there is a variety of opposing opinions in this forum held for or against testing, for or against TDD, for or against mocking, … so I hope this topic is not going to deviate towards any of them.
Back to the example, if we wanted more polymorphism, we could loosen the dependency between the controller and the business rule:
In the router
post "/purchasables", PurchasablesController, :create,
private: %{
business_rule: BusinessRules.Purchasables
}
In the controller
def create(conn, params) do
purchasable_creation_request = %PurchasableCreationRequest{
name: params.name,
approved: false,
created_at: Timex.now()}
case conn.private.business_rule.create(purchasable_creation_request, PurchasablesGateway) do
{:ok, purchasable} ->
conn
|> put_flash(:info, "Success")
|> assign(purchasable, purchasable)
|> render(:show)
{:error, reason} ->
conn
|> put_flash(:error, "Creation failed because #{reason}")
|> render(:create)
end
end
So what happened here? While the controller needs a business rule, it doesn’t need to know which one it uses. I found this morning that the plug router now allows to pass stuff via conn.private to a plug (controllers are plugs). I thought it would be a better place to set the concrete business rule used by the controller. That makes my controller code easier to test as I can test the behaviours of the controller without involving the rest of the app like the business rule and the DB gateway.
Though it doesn’t feel quite right yet. Using the router for that feels like misplaced responsibility. I’d like to find a way to compose all the things together in a different way:
- make/create/start a purchasable gateway
- make/create/start a business rule with that gateway
- make/create/start a controller with the business rule
- associate the controller with a http route
Some might say it’s a OOP mindset to try to compose things together, but it’s not reserved to OOP, for example in F#: https://blog.ploeh.dk/2015/12/21/integration-testing-composed-functions/
Today, the community seems to prefer the use of Application config with module attributes everywhere there is a need for indirection.
For example, in the controller:
defmodule PurchasablesController do
use Phoenix.Web, :controller
@business_rule Application.get_env(:my_shop_app, :purchasable_creation_rule)
def create(conn, params) do
purchasable_creation_request = %PurchasableCreationRequest{
name: params.name,
approved: false,
created_at: Timex.now()}
case @business_rule.create(purchasable_creation_request, PurchasablesGateway) do
{:ok, purchasable} ->
conn
|> put_flash(:info, "Success")
|> assign(purchasable, purchasable)
|> render(:show)
{:error, reason} ->
conn
|> put_flash(:error, "Creation failed because #{reason}")
|> render(:create)
end
end
end
I think it’s dangerous because the tests often can’t run safely and concurrently, I tend to define all tests as async: true
by default. It’s muscular memory at this point. Am I willing to trade test execution speed / time to feedback for this? No. I found using the Mox library, a good, safe and fast fix though.
I wonder if we could make these dependencies more explicit though. Whether with the router or with application config, the dependencies are rather implicit, with Application being the worse.
Let’s look back at our last version of the example (no matter which approach between the router or app config, you pick). I noticed that the business rule is now polymorphic: I can easily pick a different one. Yet the controller is still making a decision about which concrete gateway the business rule is using. I don’t think that’s the job of the controller and so the code should look like this:
In the controller:
defmodule PurchasablesController do
use Phoenix.Web, :controller
@business_rule Application.get_env(:my_shop_app, :purchasable_creation_rule)
def create(conn, params) do
purchasable_creation_request = %PurchasableCreationRequest{
name: params.name,
approved: false,
created_at: Timex.now()}
# Removed the gateway in the next line
case @business_rule.create(purchasable_creation_request) do
{:ok, purchasable} ->
conn
|> put_flash(:info, "Success")
|> assign(purchasable, purchasable)
|> render(:show)
{:error, reason} ->
conn
|> put_flash(:error, "Creation failed because #{reason}")
|> render(:create)
end
end
end
We’ve just decoupled things a bit more. Our business rule still needs a gateway though. Sure, we could once again rely on Application config to make the business rule fetch its gateway at compile time into a new module attribute. Things are getting more and more implicit now, it’s spreading quickly. Perhaps we could combine this solution with the router+option solution:
post "/purchasables", PurchasablesController, :create,
private: %{
business_rule: BusinessRules.Purchasables,
gateway: PurchasablesGateway
}
and revert the change made to the controller to
defmodule PurchasablesController do
use Phoenix.Web, :controller
@business_rule Application.get_env(:my_shop_app, :purchasable_creation_rule)
def create(conn, params) do
purchasable_creation_request = %PurchasableCreationRequest{
name: params.name,
approved: false,
created_at: Timex.now()}
case @business_rule.create(purchasable_creation_request, conn.private.gateway) do
{:ok, purchasable} ->
conn
|> put_flash(:info, "Success")
|> assign(purchasable, purchasable)
|> render(:show)
{:error, reason} ->
conn
|> put_flash(:error, "Creation failed because #{reason}")
|> render(:create)
end
end
end
Well, now we have combined 2 different solution so it’s more complex. Worse, the controller knows way too much about lots of little details to make things work, it is nosy.
What if we look at the other solution using the router options only? A solution is to define a module which wraps the actual business rule and decide what gateway should be used. That’s like decorators in the OOP world.
#Can this scale into the composition root described by ploeh in the blog shared above?
defmodule PurchasableCreationRuleWithBatteriesIncluded do
def create(purchasable_creation_request) do
BusinessRules.Purchasables.create(purchasable_creation_request, PurchasabesGateway)
end
end
In the router:
post "/purchasables", PurchasablesController, :create,
private: %{
business_rule: PurchasableCreationRuleWithBatteriesIncluded
}
The controller:
defmodule PurchasablesController do
use Phoenix.Web, :controller
def create(conn, params) do
purchasable_creation_request = %PurchasableCreationRequest{
name: params.name,
approved: false,
created_at: Timex.now()}
# the concrete business rule is set in the router and has the desired gateway baked in
case conn.private.business_rule.purchasable_creation_request) do
{:ok, purchasable} ->
conn
|> put_flash(:info, "Success")
|> assign(purchasable, purchasable)
|> render(:show)
{:error, reason} ->
conn
|> put_flash(:error, "Creation failed because #{reason}")
|> render(:create)
end
end
end
That is a solution I found while writing this up. Disclaimer: I haven’t actually tried it yet, but I still don’t feel very excited about it. It all feels like doing functional programming in older versions of Java: instead of composing functions, we are finding workarounds to compose modules, and that’s not … elegant.
Thoughts? Opinions?