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 ) 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.
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
I grabbed version2 and tweaked it and refined it and I ended up with version 1 (without looking at it) . 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
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.