
- Advanced OTP Patterns
- GenStateMachine: Managing State Transitions
- When to Use GenStateMachine
- GenEvent: Event Handling
- Application Behavior: System Startup
- Distributed Elixir
- Understanding Distributed Elixir
- Node Communication
- Distributed Registry
- Cluster Management
- Building Microservices with Elixir
- Service Discovery
- Circuit Breaker Pattern
- Best Practices and Design Patterns
- Process Design Principles
- Error Handling Strategies
- Configuration Management
- Testing Strategies
- Real-world Project Examples
- Chat Application
- API Gateway
- What's Next?
- Continuing Your Journey
- Resources
- Practice Exercises
- Key Points
Advanced OTP Patterns and Real-world Applications - Learn Elixir Part 5
Congratulations! You've made it to the final article in our Elixir learning series. By now, you should have a solid foundation in Elixir programming. In this article, we'll explore advanced OTP patterns, distributed systems, and real-world best practices that will help you build production-ready applications.
Advanced OTP Patterns
OTP provides several specialized behaviors beyond GenServer. These patterns solve specific problems and are worth understanding for complex applications.
GenStateMachine: Managing State Transitions
Sometimes you need to model systems that have distinct states with different behaviors. A traffic light is a perfect example - it cycles through red, yellow, and green, with different timing for each state.
GenStateMachine makes this straightforward:
defmodule TrafficLight do
use GenStateMachine, callback_mode: :state_functions
# Client API
def start_link do
GenStateMachine.start_link(__MODULE__, :red, name: __MODULE__)
end
def next_light do
GenStateMachine.call(__MODULE__, :next)
end
# State functions - each state has its own function
def red(:call, :next, _data) do
{:next_state, :yellow, :red_data, [{:state_timeout, 3000, :timeout}]}
end
def yellow(:call, :next, _data) do
{:next_state, :green, :yellow_data, [{:state_timeout, 5000, :timeout}]}
end
def green(:call, :next, _data) do
{:next_state, :red, :green_data, [{:state_timeout, 10000, :timeout}]}
end
end
The key insight is that each state has its own function that defines how the system behaves in that state. The :state_timeout
tells the system to automatically transition after a certain time.
When to Use GenStateMachine
Use GenStateMachine when:
- Your system has distinct states with different behaviors
- State transitions are well-defined
- You need automatic timeouts
- The state logic is complex enough to warrant separation
Examples include: order processing systems, game state management, workflow engines, and protocol implementations.
GenEvent: Event Handling
GenEvent is designed for event handling and logging. It allows multiple handlers to process the same events, making it great for building extensible systems.
defmodule EventLogger do
use GenEvent
def init(_args) do
{:ok, []}
end
def handle_event({:log, level, message}, state) do
timestamp = DateTime.utc_now() |> DateTime.to_iso8601()
IO.puts("[#{timestamp}] #{level}: #{message}")
{:ok, [message | state]}
end
def handle_call({:get_logs}, state) do
{:ok, Enum.reverse(state), state}
end
end
# Usage
defmodule EventExample do
def start_logger do
{:ok, pid} = GenEvent.start_link(name: :logger)
GenEvent.add_handler(pid, EventLogger, [])
pid
end
def log_event(level, message) do
GenEvent.notify(:logger, {:log, level, message})
end
end
The beauty of GenEvent is that you can add multiple handlers to the same event manager. Each handler processes the same events independently.
Application Behavior: System Startup
The Application behavior is what ties everything together. It's responsible for starting your application and managing its lifecycle.
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
# Start the database connection pool
{MyApp.Repo, []},
# Start the main supervisor
{MyApp.Supervisor, []},
# Start the web endpoint
{MyAppWeb.Endpoint, []}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
def config_change(changed, _new, removed) do
MyAppWeb.Endpoint.config_change(changed, removed)
:ok
end
end
The Application behavior ensures that your system starts in the right order and handles configuration changes gracefully.
Distributed Elixir
Elixir's distributed capabilities are what make it truly powerful for building scalable systems. You can run your application across multiple machines, and they can communicate seamlessly.
Understanding Distributed Elixir
Distributed Elixir is built on Erlang's distribution system. Each node (machine running Erlang) can communicate with other nodes as if they were local processes. This makes building distributed systems much simpler than with other languages.
Node Communication
defmodule DistributedExample do
def start_node do
# Start a node with a name
Node.start(:"worker@192.168.1.100")
Node.set_cookie(:my_cookie)
end
def connect_to_node(node_name) do
case Node.connect(node_name) do
true ->
IO.puts("Connected to #{node_name}")
:ok
false ->
IO.puts("Failed to connect to #{node_name}")
:error
end
end
def spawn_remote_process(node_name, module, function, args) do
Node.spawn(node_name, module, function, args)
end
def call_remote_function(node_name, module, function, args) do
:rpc.call(node_name, module, function, args)
end
end
The key functions are:
Node.start/1
: Start a named nodeNode.connect/1
: Connect to another nodeNode.spawn/4
: Spawn a process on a remote node:rpc.call/4
: Call a function on a remote node
Distributed Registry
When you have multiple nodes, you need a way to find processes across the cluster. A distributed registry solves this:
defmodule DistributedRegistry do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, [], name: {:via, Registry, {MyRegistry, __MODULE__}})
end
def register(name, pid) do
GenServer.call(__MODULE__, {:register, name, pid})
end
def lookup(name) do
GenServer.call(__MODULE__, {:lookup, name})
end
@impl true
def init(_args) do
{:ok, %{}}
end
@impl true
def handle_call({:register, name, pid}, _from, state) do
new_state = Map.put(state, name, pid)
{:reply, :ok, new_state}
end
@impl true
def handle_call({:lookup, name}, _from, state) do
result = Map.get(state, name)
{:reply, result, state}
end
end
The {:via, Registry, {MyRegistry, __MODULE__}}
syntax tells the system to use a distributed registry for process naming.
Cluster Management
Managing a cluster involves monitoring node health, handling node failures, and ensuring data consistency:
defmodule ClusterManager do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def join_cluster(node_name) do
GenServer.call(__MODULE__, {:join, node_name})
end
def get_cluster_status do
GenServer.call(__MODULE__, :get_status)
end
@impl true
def init(_args) do
# Monitor node connections
:net_kernel.monitor_nodes(true)
{:ok, %{nodes: MapSet.new()}}
end
@impl true
def handle_call({:join, node_name}, _from, state) do
case Node.connect(node_name) do
true ->
new_nodes = MapSet.put(state.nodes, node_name)
{:reply, :ok, %{state | nodes: new_nodes}}
false ->
{:reply, {:error, "Failed to connect"}, state}
end
end
@impl true
def handle_call(:get_status, _from, state) do
{:reply, MapSet.to_list(state.nodes), state}
end
# Handle node up/down events
@impl true
def handle_info({:nodeup, node}, state) do
IO.puts("Node #{node} joined the cluster")
new_nodes = MapSet.put(state.nodes, node)
{:noreply, %{state | nodes: new_nodes}}
end
@impl true
def handle_info({:nodedown, node}, state) do
IO.puts("Node #{node} left the cluster")
new_nodes = MapSet.delete(state.nodes, node)
{:noreply, %{state | nodes: new_nodes}}
end
end
The :net_kernel.monitor_nodes/1
function tells the system to send messages when nodes join or leave the cluster.
Building Microservices with Elixir
Elixir is excellent for building microservices because of its lightweight processes and built-in distribution capabilities.
Service Discovery
In a microservices architecture, services need to find each other. Here's a simple service discovery pattern:
defmodule ServiceDiscovery do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, [], name: {:via, Registry, {MyRegistry, __MODULE__}})
end
def register_service(name, endpoint) do
GenServer.call(__MODULE__, {:register, name, endpoint})
end
def discover_service(name) do
GenServer.call(__MODULE__, {:discover, name})
end
@impl true
def init(_args) do
{:ok, %{}}
end
@impl true
def handle_call({:register, name, endpoint}, _from, state) do
new_state = Map.put(state, name, endpoint)
{:reply, :ok, new_state}
end
@impl true
def handle_call({:discover, name}, _from, state) do
result = Map.get(state, name)
{:reply, result, state}
end
end
Circuit Breaker Pattern
Circuit breakers prevent cascading failures in distributed systems:
defmodule CircuitBreaker do
use GenServer
def start_link(name) do
GenServer.start_link(__MODULE__, name, name: {:via, Registry, {MyRegistry, name}})
end
def call(name, fun) do
GenServer.call({:via, Registry, {MyRegistry, name}}, {:call, fun})
end
@impl true
def init(name) do
{:ok, %{
name: name,
state: :closed,
failure_count: 0,
last_failure_time: nil,
threshold: 5,
timeout: 60_000
}}
end
@impl true
def handle_call({:call, fun}, _from, state) do
case state.state do
:open ->
if should_attempt_reset?(state) do
# Try to reset the circuit
attempt_call(fun, %{state | state: :half_open})
else
{:reply, {:error, :circuit_open}, state}
end
:closed ->
attempt_call(fun, state)
:half_open ->
attempt_call(fun, state)
end
end
defp attempt_call(fun, state) do
try do
result = fun.()
new_state = %{state |
state: :closed,
failure_count: 0,
last_failure_time: nil
}
{:reply, {:ok, result}, new_state}
rescue
_ ->
new_failure_count = state.failure_count + 1
new_state = %{state |
failure_count: new_failure_count,
last_failure_time: DateTime.utc_now()
}
if new_failure_count >= state.threshold do
{:reply, {:error, :circuit_open}, %{new_state | state: :open}}
else
{:reply, {:error, :service_failed}, new_state}
end
end
end
defp should_attempt_reset?(state) do
case state.last_failure_time do
nil -> true
time ->
DateTime.diff(DateTime.utc_now(), time, :millisecond) > state.timeout
end
end
end
The circuit breaker has three states:
- Closed: Normal operation
- Open: Service is failing, reject requests
- Half-open: Testing if service has recovered
Best Practices and Design Patterns
Process Design Principles
When designing processes in Elixir, follow these principles:
- Single Responsibility: Each process should have one clear purpose
- Message Passing: Use messages for communication, not shared state
- Supervision: Always supervise processes that can fail
- Isolation: Failures should be contained within processes
Error Handling Strategies
defmodule RobustWorker do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def process_data(data) do
GenServer.call(__MODULE__, {:process, data})
end
@impl true
def init(_args) do
{:ok, %{processed_count: 0}}
end
@impl true
def handle_call({:process, data}, _from, state) do
try do
result = do_process(data)
new_state = %{state | processed_count: state.processed_count + 1}
{:reply, {:ok, result}, new_state}
rescue
e ->
# Log the error but don't crash
IO.puts("Error processing data: #{inspect(e)}")
{:reply, {:error, "Processing failed"}, state}
end
end
defp do_process(data) do
# Simulate processing that might fail
if :rand.uniform(10) == 1 do
raise "Random processing error"
else
"Processed: #{data}"
end
end
end
Configuration Management
Use configuration to make your applications flexible:
# config/config.exs
import Config
config :my_app,
max_retries: 3,
timeout: 5000,
pool_size: 10
config :my_app, MyApp.Worker,
batch_size: 100,
interval: 1000
# config/dev.exs
import Config
config :my_app,
debug: true,
log_level: :debug
# config/prod.exs
import Config
config :my_app,
debug: false,
log_level: :info
Testing Strategies
Testing in Elixir is straightforward and powerful:
defmodule MyAppTest do
use ExUnit.Case
doctest MyApp
setup do
# Start a test process for each test
{:ok, pid} = MyApp.Worker.start_link()
{:ok, worker: pid}
end
test "worker processes data correctly", %{worker: worker} do
result = MyApp.Worker.process_data("test")
assert {:ok, "Processed: test"} = result
end
test "worker handles errors gracefully", %{worker: worker} do
# Test error handling
result = MyApp.Worker.process_data("error")
assert {:error, _} = result
end
# Property-based testing
property "worker always returns valid results" do
check all data <- string() do
result = MyApp.Worker.process_data(data)
assert is_tuple(result)
assert tuple_size(result) == 2
end
end
end
Real-world Project Examples
Chat Application
Here's a simplified chat application that demonstrates many Elixir concepts:
defmodule ChatApp do
use Application
def start(_type, _args) do
children = [
{Phoenix.PubSub, name: ChatApp.PubSub},
{ChatApp.RoomSupervisor, []},
{ChatAppWeb.Endpoint, []}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
end
defmodule ChatApp.RoomSupervisor do
use Supervisor
def start_link do
Supervisor.start_link(__MODULE__, [], name: __MODULE__)
end
@impl true
def init(_args) do
children = [
{ChatApp.RoomRegistry, []},
{ChatApp.RoomManager, []}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
defmodule ChatApp.Room do
use GenServer
def start_link(room_name) do
GenServer.start_link(__MODULE__, room_name, name: {:via, Registry, {ChatApp.RoomRegistry, room_name}})
end
def join(room_name, user) do
GenServer.cast({:via, Registry, {ChatApp.RoomRegistry, room_name}}, {:join, user})
end
def send_message(room_name, user, message) do
GenServer.cast({:via, Registry, {ChatApp.RoomRegistry, room_name}}, {:message, user, message})
end
@impl true
def init(room_name) do
Phoenix.PubSub.subscribe(ChatApp.PubSub, "room:#{room_name}")
{:ok, %{name: room_name, users: MapSet.new(), messages: []}}
end
@impl true
def handle_cast({:join, user}, state) do
new_users = MapSet.put(state.users, user)
broadcast_message(state.name, "System", "#{user} joined the room")
{:noreply, %{state | users: new_users}}
end
@impl true
def handle_cast({:message, user, message}, state) do
broadcast_message(state.name, user, message)
new_messages = [{user, message, DateTime.utc_now()} | state.messages]
{:noreply, %{state | messages: new_messages}}
end
defp broadcast_message(room_name, user, message) do
Phoenix.PubSub.broadcast(
ChatApp.PubSub,
"room:#{room_name}",
{:message, user, message, DateTime.utc_now()}
)
end
end
This chat application demonstrates:
- Supervision: RoomSupervisor manages room processes
- Registry: RoomRegistry for finding room processes
- PubSub: Broadcasting messages to connected clients
- GenServer: Managing room state
- Process isolation: Each room is a separate process
API Gateway
An API gateway that demonstrates load balancing and circuit breaking:
defmodule ApiGateway do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def route_request(path, method, body) do
GenServer.call(__MODULE__, {:route, path, method, body})
end
@impl true
def init(_args) do
# Start circuit breakers for each service
CircuitBreaker.start_link(:user_service)
CircuitBreaker.start_link(:order_service)
CircuitBreaker.start_link(:payment_service)
{:ok, %{
services: %{
"/users" => :user_service,
"/orders" => :order_service,
"/payments" => :payment_service
}
}}
end
@impl true
def handle_call({:route, path, method, body}, _from, state) do
service = find_service(path, state.services)
case service do
nil ->
{:reply, {:error, :not_found}, state}
service_name ->
CircuitBreaker.call(service_name, fn ->
call_service(service_name, path, method, body)
end)
|> then(fn result -> {:reply, result, state} end)
end
end
defp find_service(path, services) do
Enum.find_value(services, fn {prefix, service} ->
if String.starts_with?(path, prefix), do: service
end)
end
defp call_service(service_name, path, method, body) do
# Simulate service call
case service_name do
:user_service -> {:ok, %{id: 1, name: "John"}}
:order_service -> {:ok, %{id: 123, items: ["item1", "item2"]}}
:payment_service -> {:ok, %{status: "processed"}}
end
end
end
What's Next?
Congratulations! You've completed a comprehensive journey through Elixir programming. You now have the knowledge and skills to build robust, scalable applications.
Continuing Your Journey
Here are some ways to continue learning:
- Build Real Projects: Start with small applications and gradually increase complexity
- Join the Community: Participate in Elixir forums, Discord channels, and meetups
- Read the Documentation: Elixir has excellent documentation
- Contribute to Open Source: Many Elixir projects welcome contributions
- Explore the Ecosystem: Look into libraries like Nerves (IoT), Broadway (data processing), and Livebook (notebooks)
Resources
- Official Documentation: https://elixir-lang.org/docs.html
- Phoenix Framework: https://hexdocs.pm/phoenix/
- Ecto: https://hexdocs.pm/ecto/
- Elixir Forum: https://elixirforum.com/
- Elixir Weekly: Newsletter with latest updates
Practice Exercises
Try these advanced exercises to solidify your knowledge:
- Distributed System: Build a simple distributed key-value store
- Microservice: Create a user service with authentication and authorization
- Real-time Application: Build a collaborative document editor
- Data Processing: Implement a stream processing pipeline
Key Points
- Advanced OTP patterns solve specific problems in distributed systems
- Distributed Elixir makes building cluster-aware applications straightforward
- Microservices benefit from Elixir's lightweight processes and fault tolerance
- Best practices focus on process isolation, supervision, and error handling
- Real-world applications combine multiple Elixir concepts
- The Elixir ecosystem provides tools for almost any application type
- Continuous learning and community involvement are key to mastery
You're now ready to build amazing applications with Elixir!
- Advanced OTP Patterns
- GenStateMachine: Managing State Transitions
- When to Use GenStateMachine
- GenEvent: Event Handling
- Application Behavior: System Startup
- Distributed Elixir
- Understanding Distributed Elixir
- Node Communication
- Distributed Registry
- Cluster Management
- Building Microservices with Elixir
- Service Discovery
- Circuit Breaker Pattern
- Best Practices and Design Patterns
- Process Design Principles
- Error Handling Strategies
- Configuration Management
- Testing Strategies
- Real-world Project Examples
- Chat Application
- API Gateway
- What's Next?
- Continuing Your Journey
- Resources
- Practice Exercises
- Key Points