Callbacks are special functions that participate in Malla's compile-time callback chain, allowing for extensible and composable service behavior. They are the core mechanism through which plugins interact and modify functionality.
Defining Callbacks
You define a callback using the defcb macro instead of the standard def.
defmodule MyService do
use Malla.Service
# This is a regular function and does not participate in the callback chain.
def regular_function(arg) do
arg * 2
end
# This is a callback and is part of the plugin chain.
defcb my_callback(arg) do
# Process the argument
result = process(arg)
# Continue the chain, passing the modified result to the next plugin.
{:cont, result}
end
# This callback might stop the chain based on a condition.
defcb another_callback(arg) do
if should_stop?(arg) do
# Stops the chain and returns this value to the original caller.
{:ok, :stopped}
else
# Continues the chain with the same arguments.
:cont
end
end
endAt compile time, the defcb macro renames your implementation (e.g., to my_callback_malla_service) and generates a final dispatching version of the function that handles walking the plugin chain. This means there is zero runtime overhead for the callback dispatch mechanism.
Accessing the Service ID
Before Malla invokes a callback on a service, it stores the service's ID in the process dictionary. This allows you to easily access the current service ID from within your callback implementation using Malla.get_service_id/0.
Many functions in the Malla API are context-aware. If you omit the service_id argument when calling them from within a callback, they will automatically use the ID from the process dictionary.
defmodule MyService do
use Malla.Service
defcb my_callback(arg) do
# Retrieve the current service ID
service_id = Malla.get_service_id()
# Call a Malla function without explicitly passing the service_id.
# It will automatically use the service_id from the process dictionary.
Malla.some_function(arg)
:cont
end
endThis feature simplifies your code by reducing the need to pass the service_id around manually.
Controlling the Chain with Return Values
The value returned by a defcb callback determines how the chain proceeds.
:cont - Continue with Same Arguments
Return :cont to signal that the next plugin in the chain should be called with the exact same arguments.
defcb process(data) do
# Perform a side-effect, like logging.
Logger.info("Processing data: #{inspect(data)}")
# Continue the chain.
:cont
end{:cont, new_args} - Continue with Modified Arguments
Return a tuple starting with :cont to continue the chain but with a new set of arguments. The arguments can be in a list or as separate elements in the tuple.
# Using a list of arguments
defcb transform(data) do
transformed = transform_data(data)
{:cont, [transformed]}
end
# Using multiple elements in the tuple
defcb split(data1, data2) do
part1b = transform_data(data1)
{:cont, [part1b, part2]}
endAny Other Value - Stop the Chain
Return any value that is not :cont or a {:cont, ...} tuple to stop the execution of the callback chain immediately. This value will be returned to the original caller.
defcb validate(data) do
if valid?(data) do
# Data is valid, let the chain continue.
:cont
else
# Data is invalid, stop the chain and return an error.
{:error, :invalid_data}
end
endA Note on Catch-All Clauses
When implementing a callback, it is good practice to include a "catch-all" clause that returns :cont. This ensures that your callback gracefully handles arities or argument patterns it doesn't explicitly support, allowing other plugins in the chain to continue processing.
For example:
defmodule MyRobustPlugin do
use Malla.Plugin
# Specifically handle a 2-tuple
defcb my_callback({key, value}) do
# process the tuple
{:cont, {key, process_value(value)}}
end
# Catch-all for any other arguments
defcb my_callback(_args) do
:cont
end
endWithout the catch-all clause, a call to my_callback with an unexpected argument (e.g., my_callback("some string")) would raise a FunctionClauseError, halting the entire callback chain. The catch-all prevents this by simply allowing the chain to continue unmodified.
How Callback Chains Work
Execution Flow Diagram
graph TB
Start(["Call: MyService.process(5)"])
A["MyService.process(5)<br/>Output: 'Service processing: 5'<br/>Returns: :cont"]
B["PluginB.process(5)<br/>Output: 'Plugin B processing: 5'<br/>Returns: tuple(:cont, 10)"]
C["PluginA.process(10)<br/>Output: 'Plugin A processing: 10'<br/>Returns: tuple(:cont, 11)"]
End(["Return 11 to caller"])
Start --> A
A -->|Continue with same args: 5| B
B -->|Continue with modified args: 10| C
C -->|End of chain| End
style Start fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
style End fill:#fff3e0,stroke:#ff9800,stroke-width:2px
style A fill:#e3f2fd,stroke:#2196f3
style B fill:#f3e5f5,stroke:#9c27b0
style C fill:#fce4ec,stroke:#e91e63Example Code
Given the following setup:
defmodule PluginA do
use Malla.Plugin
defcb process(x) do
IO.puts("Plugin A processing: #{x}")
{:cont, x + 1}
end
end
defmodule PluginB do
use Malla.Plugin,
plugin_deps: [PluginA] # Depends on PluginA
defcb process(x) do
IO.puts("Plugin B processing: #{x}")
{:cont, x * 2}
end
end
defmodule MyService do
use Malla.Service,
plugins: [PluginB] # Depends on PluginB
defcb process(x) do
IO.puts("Service processing: #{x}")
:cont
end
endThe dependency hierarchy is MyService → PluginB → PluginA. When you call MyService.process(5), the execution flows from top to bottom:
-
MyService.process(5)is called. It prints "Service processing: 5" and returns:cont. - The chain continues to
PluginB.process(5). It prints "Plugin B processing: 5" and returns{:cont, 10}. The argument for the next plugin is now10. - The chain continues to
PluginA.process(10). It prints "Plugin A processing: 10" and returns{:cont, 11}. - The chain has reached the end. The final value
11is returned to the original caller.