Calling protocol function outside the module that defines it

Let’s say we’ve got a module called Queue which defines a protocol called Queueable. Which one of these implementations is correct, and why? My opinion is at the bottom but I couldn’t come up with more concrete reasons (or examples?) of why we should be using the 1st implementation. Maybe I am totally wrong anyway, so I’d like to hear (or read :stuck_out_tongue:) your opinion.

Implementation 1

defprotocol Queueable do
  def extract_job_params_from(payload)
end

defmodule Queue do
  def enqueue(queueable) do
    queueable
    |> Queueable.extract_job_params_from()
    |> changeset()
    |> Repo.insert()
  end
end

defimpl Queueable, for: Payload do
  def extract_job_params(payload) do
    # Do the mapping
    # Returns a map that can be inserted into the Queue
  end
end

Implementation 2

defprotocol Queueable do
  def enqueue(payload)
end

defmodule Queue do
  def enqueue(job_params) do
    job_params
    |> changeset()
    |> Repo.insert()
  end
end

defimpl Queueable, for: Payload do
  def enqueue(payload) do
    payload
    |> extract_job_params()
    # Notice we call Queue implementation from within protocol function
    |> Queue.enqueue()
  end
end

My opinion is, the first implementation is the way to go because Queue is the thing that defines the protocol hence calling the Queue from within a protocol function doesn’t seem nice.

Also if we had some other functions in Queue module then we would be calling Queueable.enqueue for enqueuing stuff but then we would be calling other functions on Queue (e.g. Queue.get_job).

I think of a protocol as an agreement between two modules, in this case Queue and Payload. So, as long as some module implements the protocol then Queue will be doing the right job.

Looking at other implementations like Enumerable and Inspect we can see that, they are also implemented like the way in implementation 1.

Cheers!

1 Like

where is defined extract_job_params/1 in version 2?

Inside the protocol implementation, as a private function perhaps.

Sounds like extract_job_params_from would be an implementation detail.
If you think it could be used by other functions you can make it part of your contract.
Additionally you can provide a default version by using __using__ and make it an @optional_callbacks.

Not sure whether that answers my question. I was actually asking more about the place protocol function is invoked. The difference between the two implementations.

Did you mean something like

defprotocol Queueable do
   use Details
end

defmodule Details do
  defmacro __using__ do
     # extract_job_params_from goes here?
  end
end

How do you call enqueue on your enqueueable in your second version?
What’s the API for that?

I grabbed version2 and tweaked it and refined it and I ended up with version 1 (without looking at it) :laughing:. So I will say version 1 is the way to go.

UPDATE:
The only change that I will make is in the Queue API, so long as you use queueable , if not you can get rid of it.

defmodule Queue do
  def enqueue(queueable, job_params \\ nil)

  def enqueue(queueable, nil) do
    job_params = Queueable.extract_job_params(queueable)
    enqueue(queueable, job_params)
  end

  def enqueue(_queueable, job_params) when is_map(job_params) do
    job_params
    |> changeset()
    |> repo_insert()
  end
end
1 Like

You’ve probably already figured that out but that’s the problem. If you go with the 2nd version you do something like Queueable.enqueue(enqueueable).

That’s an indicator why we should also go with the 1st implementation I guess, as that feels more natural. :smiley:

Thanks!

1 Like

Also, depending on an External module which depends back on the implementation sounds like code smell, if not a cyclic dependecy

2 Likes

In my view the first one is preferable, because the requirements on Queueable implementations are simpler and explicit: one merely has to implement the functions declared in the protocol. The second way, instead, includes an additional implicit requirement on the protocol implementation: it has to call Queue.enqueue. Therefore, things are more explicit and less tightly coupled in the first version.

If in the future one would need to change the interface of Queue.enqueue, or introduce a different AlternativeQueue that can also use the protocol, the first version can do so without having to change all implementations.

2 Likes