Elixir and OTP: A Comprehensive Guide to Process-oriented Programming

Programmers often classify programming languages into paradigms like object-oriented, imperative, and functional. This helps in identifying languages suitable for similar tasks. However, while languages like Elixir (and Erlang before it) share characteristics with functional languages, their reliance on OTP suggests a distinct process-oriented paradigm.

This article delves into process-oriented programming using these languages, contrasting it with other paradigms and highlighting its implications for learning and practical application. We’ll conclude with a concise process-oriented programming illustration.

Defining Process-oriented Programming

Process-oriented programming, rooted in Communicating Sequential Processes from a 1977 paper by Tony Hoare (also known as the actor model), prioritizes the structure and interaction of processes within a system. Languages like Occam, Limbo, and Go share similarities with this model. While the original concept focused on synchronous communication, many, including OTP, utilize asynchronous communication as well. OTP, built on this foundation, enables fault-tolerant systems through communicating sequential processes. Its “let it fail” philosophy, coupled with robust error recovery via supervisors and distributed processing, proves more reliable than the “prevent it from failing” approach.

In essence, process-oriented programming emphasizes the structure and communication channels of processes as the core aspects of system design.

Comparing Paradigms

Object-oriented vs. Process-oriented

Object-oriented programming revolves around the organization of data and functions within objects and classes. UML class diagrams epitomize this focus, as depicted in Figure 1.

Process-oriented programming: Sample UML class diagram

A common criticism of object-oriented programming is the lack of clear control flow visibility, especially in complex systems. Conversely, its strength lies in the ease of extending systems with new object types that adhere to established conventions.

Functional vs. Process-oriented

Functional programming prioritizes immutable data and function manipulation. While many handle concurrency, their primary scope remains within a single address space. Communication between executables typically relies on operating system-specific methods.

Take Scala is a functional language, for example. Its foundation on the Java Virtual Machine allows access to Java’s communication features, but this isn’t inherent to the language. Similarly, its use in Spark is through a library.

Functional programming excels in control flow visualization due to its explicit function calls and lack of side effects. However, managing persistent state, crucial for real-world applications, poses a challenge. Well-designed functional systems handle this at the top level, preserving side-effect-free operation for most components.

Elixir/OTP and Process-oriented Programming

In Elixir/Erlang and OTP, communication primitives are integral to the virtual machine. Inter-process and inter-machine communication are fundamental, emphasizing the paramount importance of communication in this paradigm.

While Elixir’s logic is largely functional, its application is inherently process-oriented.

Understanding Process Orientation

Process-oriented design prioritizes identifying the types of processes needed and their interactions. Key considerations include process lifespan, system load, expected data volume and velocity. Only after addressing these factors does the logic within each process come into play.

Implications for Learning

Training should emphasize systems thinking and process allocation over language syntax. A deep understanding of OTP, lifecycle management, quality assurance, DevOps, and business needs is crucial.

Implications for Adoption

Process-oriented languages excel in distributed systems or those requiring extensive communication. They might be less suitable for single-workload, single-computer scenarios. Their inherent fault tolerance makes them ideal for long-running systems.

For documentation and design, sequence diagrams (Figure 2) effectively illustrate temporal relationships between processes. Simple box-and-arrow diagrams can represent process types and relationships (Figure 3).

Process-oriented programming sample UML sequence diagram
Process-oriented programming sample process structure diagram

A Process-oriented Example: Global Election System

Let’s design a system to manage global elections, handling vote casting, real-time aggregation, and result presentation.

Initial Design

  • Vote Collection: Numerous processes, potentially distributed geographically, receive and log votes, forwarding them in batches to aggregators.
  • Vote Aggregation: Dedicated processes, potentially one per country and state/province, compute and hold real-time results.
  • Result Presentation: Processes, possibly distributed geographically, cache and serve results to users, minimizing load on aggregators.

This initial process-agnostic design ensures scalability, geographic distribution, and data integrity through acknowledgments. (Figure 4)

Process-oriented development example: Initial process design

Incorporating Complexity

Introducing jurisdiction-specific rules for vote aggregation and result determination necessitates a revised process structure. Results from state/province aggregators now feed into country-level aggregators. Reusing the communication protocol allows logic reuse, but distinct processes and communication paths are required. (Figure 5)

Process-oriented development example: Modified process design

Code Implementation

The following Elixir OTP code snippets demonstrate the example’s implementation, assuming a separate web server (e.g., Phoenix) handles user requests. Full source code with tests is available at https://github.com/technomage/voting.

Vote Recorder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
defmodule Voting.VoteRecorder do
  @moduledoc """
  This module receives votes and sends them to the proper
  aggregator. This module uses supervised tasks to ensure
  that any failure is recovered from and the vote is not
  lost.
  """

  @doc """
  Start a task to track the submittal of a vote to an
  aggregator. This is a supervised task to ensure
  completion.
  """
  def cast_vote where, who do
    Task.Supervisor.async_nolink(Voting.VoteTaskSupervisor,
      fn ->
        Voting.Aggregator.submit_vote where, who
      end)
    |> Task.await
  end
end

Vote Aggregator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
defmodule Voting.Aggregator do
  use GenStage
  ...

  @doc """
  Submit a single vote to an aggregator
  """
  def submit_vote id, candidate do
    pid = __MODULE__.via_tuple(id)
    :ok = GenStage.call pid, {:submit_vote, candidate}
  end

  @doc """
  Respond to requests
  """
  def handle_call {:submit_vote, candidate}, _from, state do
    n = state.votes[candidate] || 0
    state = %{state | votes: Map.put(state.votes, candidate, n+1)}
    {:reply, :ok, [%{state.id => state.votes}], state}
  end

  @doc """
  Handle events from subordinate aggregators
  """
  def handle_events events, _from, state do
    votes = Enum.reduce events, state.votes, fn e, votes ->
      Enum.reduce e, votes, fn {k,v}, votes ->
        Map.put(votes, k, v) # replace any entries for subordinates
      end
    end
    # Any jurisdiction specific policy would go here

    # Sum the votes by candidate for the published event
    merged = Enum.reduce votes, %{}, fn {j, jv}, votes ->
      # Each jourisdiction is summed for each candidate
      Enum.reduce jv, votes, fn {candidate, tot}, votes ->
        Logger.debug "@@@@ Votes in #{inspect j} for #{inspect candidate}: #{inspect tot}"
        n = votes[candidate] || 0
        Map.put(votes, candidate, n + tot)
      end
    end
    # Return the published event and the state which retains
    # Votes by jourisdiction
    {:noreply, [%{state.id => merged}], %{state | votes: votes}}
  end
end

Result Presenter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
defmodule Voting.ResultPresenter do
  use GenStage
  

  @doc """
  Handle requests for results
  """
  def handle_call :get_votes, _from, state do
    {:reply, {:ok, state.votes}, [], state}
  end

  @doc """
  Obtain the results from this presenter
  """
  def get_votes id do
    pid = Voting.ResultPresenter.via_tuple(id)
    {:ok, votes} = GenStage.call pid, :get_votes
    votes
  end

  @doc """
  Receive votes from aggregator
  """
  def handle_events events, _from, state do
    Logger.debug "@@@@ Presenter received: #{inspect events}"
    votes = Enum.reduce events, state.votes, fn v, votes ->
      Enum.reduce v, votes, fn {k,v}, votes ->
        Map.put(votes, k, v)
      end
    end
    {:noreply, [], %{state | votes: votes}}
  end
end

Conclusion

This exploration highlighted Elixir/OTP’s strength as a process-oriented language, comparing it to other paradigms and illustrating its practical application. The key takeaway: prioritize a process-centric design approach, considering system-level interactions before delving into code logic.

For those interested, the complete example code can be found on GitHub.

Licensed under CC BY-NC-SA 4.0