Getting Started with Malla

Copy Markdown View Source
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:

  1. Create a simple service with an API
  2. Extract functionality into reusable plugins
  3. Compose plugins to modify behavior
  4. 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:

  1. CalculatorV3 checks first (top of chain) - returns :cont
  2. MultiplicationPlugin checks - operation is :add, returns :cont
  3. AdditionPlugin checks - operation is :add, returns {:ok, 12}
  4. 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:

  1. Automatic inclusion - dependent plugins are pulled in transitively, so the service only needs to list the top-level plugin.
  2. 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):

  1. CalculatorV4 (top) - no defcb calculate, returns :cont
  2. LoggingPlugin - prints >> calculate(multiply, 6, 7), returns :cont
  3. MultiplicationPlugin - handles :multiply, returns {:ok, 42}
  4. 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

  1. Services are the main building blocks - they define what your system does
  2. Plugins are reusable pieces of functionality that can be composed together
  3. Callbacks (defined with defcb) form chains that execute top-to-bottom
  4. Plugin dependencies control chain order - dependent plugins run before their dependencies
  5. Return :cont to continue the chain, or any other value to stop and return
  6. Configuration - any non-reserved key in use Malla.Service becomes config, readable with get_config/0 and changeable with reconfigure/2
  7. Plugins can be added/removed at runtime with add_plugin/2 and del_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.