Based on Sasa Juric's blog series (towards maintainable elixir), struggling a bit with passing more than 4 params to core functions

Trying to follow some of the patterns Sasa describes at https://medium.com/very-big-things/towards-maintainable-elixir-the-core-and-the-interface-c267f0da43

It works really nice when functions require 2-4 params, but more than that requires to start using a well formed map. He lays out this in one of the comments

  1. Normalize the free-form input in the interface into a well-shaped data (maps or structs)
  2. Pass that data to the core
  3. Have core only return well-shaped results on success.
  4. Returning a changeset on error is fine as long special branching on the type of error in the client (interface)
    code is not required.

So I used a struct to type a map that I use as input, something along this lines

# my_context.ex

defmodule MyApp.MyContext.CreateParams do
  @enforce_keys [:name]
  @type t() :: %__MODULE__{name: String.t(), modified_at: DateTime.t() | nil, ...a bunch of others...}

  defstruct [::name, modified_at: nil, ... and a bunch of others...]
end

defmodule MyApp.MyContext do

  @type create(CreateParams.t()) :: {:ok, Thing.t()}
  def create(params) do
     # creation logic
  end

end

Now, this works, but is quite ugly as I need to alias that CreateParams module whenever I want to call create to comply with spec, I need to start inventing names for that struct module and worst of all, I’m not sure where is best to place that module (for now I colocate in the same file as my context)

Is there a better approach to this?

3 Likes

I prefer to not use alias in my code, instead I prefer to be explicit, so that I know at a glance what I am really calling.

In my case I also use an API module to establish the boundaries between modules calling each other, therefore I always have module called MyApp.Api where I use defdelegate to map the calls and have all my modules that depend on others just coupled by MyApp.Api module. So in your case I would always have a call to MyApp.Api.create_mycontext/1 and withour using alias.

I really don’t like the usual approach of spreading the code of a resource across different folders, unless they are child folders of the current resource:

$ tree rumbl            
rumbl
β”œβ”€β”€ annotations
β”‚   β”œβ”€β”€ add
β”‚   β”‚   β”œβ”€β”€ annotation.ex
β”‚   β”‚   └── annotation_channel.ex
β”‚   β”œβ”€β”€ all
β”‚   β”‚   └── annotation_channel.ex
β”‚   β”œβ”€β”€ fetch
β”‚   β”‚   └── annotation.ex
β”‚   β”œβ”€β”€ join_channell
β”‚   β”‚   └── annotation_channel.ex
β”‚   β”œβ”€β”€ modify
β”‚   β”‚   β”œβ”€β”€ annotation.ex
β”‚   β”‚   └── annotation_channel.ex
β”‚   └── remove
β”‚       β”œβ”€β”€ annotation.ex
β”‚       └── annotation_channel.ex
β”œβ”€β”€ api.ex
β”œβ”€β”€ endpoint.ex
β”œβ”€β”€ release_tasks.ex
β”œβ”€β”€ repo.ex

the file annotation.ex is the context for that resource action.

So, in my case if it is a struct common to all actions in the the annotations resource, then I would add it into rumbl/annotations/create_params.ex or if it was common to all resources at rumbl/annotations/create_params.ex.

You can try the Domo library to have typed structs enforced at runtime. The library will also write the type specs for your therefore your code will look nicer.

Thank you for your input

Yes, I guess there are not many options, either co-located in the same file or in a different file (at which level depending on who uses it as you mentioned)

About using a function like MyApp.Api.create_mycontext/1, not sure I’m getting this right, but seems that using that function puts me in the same position of having to pass a lot of params, no? My problem was that I need to use a Map when the functions require more than 4 params (I’ll edit my example to illustrate this better)

1 Like

I should had been more explicit. Using this approach was more to address your issue of using alias everywhere you need to invoke the creation of the struct.

To try to be more clear using MyApp.Api.create_mycontext/1 is shorter then using MyApp.MyContext.CreateParams.create/1 everywhere while at same time decouples the client from the implementation.

defmodule MyApp.Api do
  defdelegate create_mycontext(params), to: MyApp.MyContext.CreateParams, as: create
end

The name create_mycontext is just a placeholder and should be replaced with what makes sense to your business domain.

We have a lot of such cases, and use plain maps (not structs):

@type create_params :: %{name: String.t(), modified_at: DateTime.t() | nil, ...a bunch of others...}

@spec create(create_params) :: ...
10 Likes

Nice, that is indeed less verbose than using a struct and saves the hassle to create a module, so is much better than what I have. Thank you @sasajuric and thank you also for the great blog post series!