Mix.install([:malla])Introduction
Welcome to Malla! This tutorial will guide you through building distributed services with a plugin-based architecture. You'll learn how to:
- Create a simple service with an API
- Extract functionality into reusable plugins
- Compose plugins to modify behavior
- Dynamically reconfigure services at runtime
Let's build a simple calculator service to demonstrate these concepts.
Part 1: Creating a Basic Service
First, let's create a simple calculator service that can add numbers.
defmodule Calculator do
use Malla.Service
# Public API - can be called remotely
def add(a, b) do
a + b
end
# Callback that participates in the plugin chain
defcb calculate(:add, a, b), do: {:ok, a + b}
end
# Start the service
{:ok, _pid} = Calculator.start_link([])
# Test the API
IO.puts("2 + 3 = #{Calculator.add(2, 3)}")
# Test the callback
{:ok, result} = Calculator.calculate(:add, 5, 7)
IO.puts("5 + 7 = #{result}")Great! We have a working service. The calculate/3 callback is special - it participates in a callback chain where plugins can intercept and modify behavior.
Part 2: Moving Functionality into a Plugin
Now let's extract the addition logic into a reusable plugin. This makes it easier to share functionality across multiple services.
defmodule AdditionPlugin do
use Malla.Plugin
# This callback will be part of the chain
defcb calculate(:add, a, b), do: {:ok, a + b}
# If we don't support this, let other plugins process it
defcb calculate(_, _, _), do: :cont
end
# Create a new calculator that uses the plugin
defmodule CalculatorV2 do
use Malla.Service, plugins: [AdditionPlugin]
# The service doesn't need to implement calculate/3
# The plugin handles it!
end
# Start and test
{:ok, _pid} = CalculatorV2.start_link([])
{:ok, result} = CalculatorV2.calculate(:add, 10, 20)
IO.puts("Using plugin: 10 + 20 = #{result}")Notice how the service became simpler? The plugin now handles the calculation logic.
Part 3: Adding More Functionality with Another Plugin
Let's add multiplication by creating another plugin. This demonstrates how plugins compose together.
defmodule MultiplicationPlugin do
use Malla.Plugin
defcb calculate(:multiply, a, b), do: {:ok, a * b}
defcb calculate(_, _, _), do: :cont
end
# Create a calculator with both plugins
defmodule CalculatorV3 do
use Malla.Service,
plugins: [
AdditionPlugin,
MultiplicationPlugin
]
end
# Start and test both operations
{:ok, _pid} = CalculatorV3.start_link([])
{:ok, sum} = CalculatorV3.calculate(:add, 8, 4)
IO.puts("8 + 4 = #{sum}")
{:ok, product} = CalculatorV3.calculate(:multiply, 8, 4)
IO.puts("8 × 4 = #{product}")How the Callback Chain Works
When you call CalculatorV3.calculate(:add, 8, 4), here's what happens:
- CalculatorV3 checks first (top of chain) - returns
:cont - MultiplicationPlugin checks - operation is
:add, returns:cont - AdditionPlugin checks - operation is
:add, returns{:ok, 12}✓ - Chain stops - the result is returned
The chain walks from top to bottom until a plugin returns something other than :cont.
Part 4: Plugin Dependencies and Chain Ordering
A plugin can declare dependencies on other plugins using plugin_deps. This has two effects:
- Automatic inclusion - dependent plugins are pulled in transitively, so the service only needs to list the top-level plugin.
- Chain ordering - the dependent plugin is placed above its dependencies in the callback chain, so it gets called first.
Let's create a LoggingPlugin that depends on both math plugins and logs every operation to screen before letting them handle it.
defmodule LoggingPlugin do
use Malla.Plugin,
plugin_deps: [AdditionPlugin, MultiplicationPlugin]
defcb calculate(operation, a, b) do
# This runs BEFORE the math plugins because LoggingPlugin
# is above them in the chain (it depends on them).
IO.puts(">> calculate(#{operation}, #{a}, #{b})")
# Return :cont to let the math plugins handle the actual computation
:cont
end
end
# We only need to list LoggingPlugin - its dependencies
# (AdditionPlugin and MultiplicationPlugin) are included automatically.
defmodule CalculatorV4 do
use Malla.Service,
plugins: [LoggingPlugin]
end
# Start and test - watch the logs!
{:ok, _pid} = CalculatorV4.start_link([])
{:ok, sum} = CalculatorV4.calculate(:add, 6, 7)
IO.puts("= #{sum}\n")
{:ok, product} = CalculatorV4.calculate(:multiply, 6, 7)
IO.puts("= #{product}")Chain Order with Dependencies
The chain for CalculatorV4.calculate(:multiply, 6, 7):
- CalculatorV4 (top) - no
defcb calculate, returns:cont - LoggingPlugin - prints
>> calculate(multiply, 6, 7), returns:cont - MultiplicationPlugin - handles
:multiply, returns{:ok, 42} - Chain stops - the result is returned
By declaring plugin_deps, we told Malla that LoggingPlugin sits above the math plugins. No need to manually order the plugins: list.
Part 5: Extending Behavior Without Modifying Existing Plugins
A key benefit of the plugin architecture is that you can add new behavior without touching existing code. Let's create a FloatPlugin that converts all results to floats. It depends on LoggingPlugin, so it sits above everything in the chain.
defmodule FloatPlugin do
use Malla.Plugin,
plugin_deps: [LoggingPlugin, MultiplicationPlugin, AdditionPlugin]
defcb calculate(operation, a, b) do
# Convert arguments to floats before passing them down the chain
{:cont, [operation, a / 1, b / 1]}
end
end
# FloatPlugin depends on LoggingPlugin, which depends on both math plugins.
# All four plugins are included automatically.
defmodule CalculatorV4b do
use Malla.Service,
plugins: [FloatPlugin]
end
{:ok, _pid} = CalculatorV4b.start_link([])
{:ok, sum} = CalculatorV4b.calculate(:add, 6, 7)
IO.puts("= #{sum}")
# => 13.0 (float!)
{:ok, product} = CalculatorV4b.calculate(:multiply, 6, 7)
IO.puts("= #{product}")
# => 42.0 (float!)Notice that we didn't modify AdditionPlugin, MultiplicationPlugin, or LoggingPlugin. FloatPlugin uses {:cont, [operation, a / 1, b / 1]} to pass modified arguments down the chain, converting integers to floats before the math plugins see them. The existing plugins work unchanged - they just receive floats instead of integers.
Part 6: Adding and Removing Plugins at Runtime
Malla can add or remove plugins from a running service without stopping it. The service restarts automatically with the updated plugin chain. This is useful for debugging in production, feature rollouts, or disabling problematic plugins.
Let's start a calculator with FloatPlugin and then remove it at runtime.
defmodule CalculatorV6 do
use Malla.Service,
plugins: [FloatPlugin, MultiplicationPlugin, AdditionPlugin]
end
{:ok, _pid} = CalculatorV6.start_link([])
IO.puts("--- With FloatPlugin ---")
IO.inspect(CalculatorV6.calculate(:add, 6, 7))
# => {:ok, 13.0}
# Remove FloatPlugin at runtime
IO.puts("\n--- Removing FloatPlugin ---")
Malla.Service.del_plugin(CalculatorV6, FloatPlugin)
Process.sleep(100)
IO.inspect(CalculatorV6.calculate(:add, 6, 7))
# => {:ok, 13}
# Add it back
IO.puts("\n--- Adding FloatPlugin back ---")
Malla.Service.add_plugin(CalculatorV6, FloatPlugin)
Process.sleep(100)
IO.inspect(CalculatorV6.calculate(:add, 6, 7))
# => {:ok, 13.0}No code changes, no recompilation, no downtime. The callback chain is rebuilt and the service restarts with the new plugin configuration automatically.
Part 7: Runtime Configuration and Reconfiguration
Malla services can be configured through use Malla.Service and reconfigured at runtime with Malla.Service.reconfigure/2. By convention, configuration is organized as keyword lists under keys named after each plugin or the service itself.
Plugins can read service configuration inside callbacks using Malla.get_service_id!/0 and srv_id.get_config/0.
defmodule RoundingPlugin do
use Malla.Plugin,
plugin_deps: [AdditionPlugin, MultiplicationPlugin]
# Handle merging our config key when reconfigure/2 is called.
# Without this, updates are ignored (the default returns :ok = "skip").
@impl true
def plugin_config_merge(_srv_id, config, update) do
case update[:rounding_plugin] do
nil -> :ok
my_update ->
my_config = config |> Keyword.get(:rounding_plugin, %{}) |> Map.merge(my_update)
{:ok, Keyword.put(config, :rounding_plugin, my_config)}
end
end
defcb calculate(operation, a, b) do
srv_id = Malla.get_service_id!()
config = srv_id.get_config()
decimals = get_in(config, [:rounding_plugin, :decimals]) || 2
# Modify the arguments by rounding, then continue the chain
{:cont, [operation, Float.round(a / 1, decimals), Float.round(b / 1, decimals)]}
end
end
defmodule CalculatorV7 do
use Malla.Service,
plugins: [RoundingPlugin],
# Default config: round to 2 decimal places (use a map)
rounding_plugin: %{decimals: 2}
end
{:ok, _pid} = CalculatorV7.start_link([])
IO.puts("--- Rounding to 2 decimals (default) ---")
IO.inspect(CalculatorV7.calculate(:add, 1.005, 2.006))
# Change rounding precision at runtime (use a map)
IO.puts("\n--- Reconfiguring to 4 decimals ---")
Malla.Service.reconfigure(CalculatorV7, rounding_plugin: %{decimals: 4})
Process.sleep(100)
IO.inspect(CalculatorV7.calculate(:add, 1.005, 2.006))
# You can also pass config at startup via start_link
# CalculatorV7.start_link(rounding_plugin: %{decimals: 0})Key Takeaways
- Services are the main building blocks - they define what your system does
- Plugins are reusable pieces of functionality that can be composed together
- Callbacks (defined with
defcb) form chains that execute top-to-bottom - Plugin dependencies control chain order - dependent plugins run before their dependencies
- Return
:contto continue the chain, or any other value to stop and return - Configuration - any non-reserved key in
use Malla.Servicebecomes config, readable withget_config/0and changeable withreconfigure/2 - Plugins can be added/removed at runtime with
add_plugin/2anddel_plugin/2
Next Steps
This tutorial only scratches the surface of what Malla offers. Check out the guides and API documentation for the full picture: service lifecycle, distributed service discovery, remote calls, request handling, tracing, and more.