Skip to content

Instantly share code, notes, and snippets.

@rhomel
Last active September 18, 2020 10:31
Show Gist options
  • Save rhomel/6b1f26e27915ffd8a85d1b5c94a59b8c to your computer and use it in GitHub Desktop.
Save rhomel/6b1f26e27915ffd8a85d1b5c94a59b8c to your computer and use it in GitHub Desktop.
Event Sourcing prototype with Elixir
# Example Event Sourcing aggregate implementation in Elixir
#
# This is literally the first code that I wrote with Elixir.
# So this may not be optimal or idiomatic Elixir.
#
# Note that I heavily use Elixir's pattern matching features.
#
# Run with:
# elixir es.exs
#
# Or with iex REPL:
# iex es.exs
# # to hot reload the code in iex:
# c "es.exs"
# # ctrl+c twice to exit
#
# Or copy-paste to https://repl.it/languages/elixir to run in a web browser.
defmodule ExperimentConfig do
defstruct name: ""
end
defmodule Experiment do
defstruct config: %ExperimentConfig{}
end
defmodule Event do
defstruct name: "Unknown", properties: %{}
end
defmodule Command do
defstruct name: "Unknown", properties: %{}
end
defmodule Aggregate do
defstruct events: [], created: false, draft: nil, published: nil
defp log(command = %Command{}) do
IO.puts " Process: " <> command.name
end
defp log(event = %Event{}) do
IO.puts " Apply: " <> event.name
end
def append(aggregate = %Aggregate{}, event =%Event{}) do
log(event)
%Aggregate{aggregate | events: aggregate.events ++ [event]}
end
def new() do
%Aggregate{}
end
def new(aggregate = %Aggregate{}, []) do
aggregate
end
def new(aggregate = %Aggregate{}, events) when is_list(events) do
next = new(aggregate, hd events)
new(next, tl events)
end
def new(aggregate = %Aggregate{}, event = %Event{name: "CreateExperiment"}) do
%Aggregate{aggregate | created: true}
|> append(event)
end
def new(aggregate = %Aggregate{}, event = %Event{name: "CreateDraft", properties: %{draft: %Experiment{}}}) do
%Aggregate{aggregate | draft: event.properties.draft}
|> append(event)
end
def new(aggregate = %Aggregate{}, event = %Event{}) do
next = append(aggregate, event)
IO.puts " Warning: skipping unrecognized event: " <> event.name
next
end
def process(aggregate = %Aggregate{}, command = %Command{name: "CreateDraft"}) do
log(command)
events = [
%Event{name: "CreateExperiment"},
%Event{name: "CreateDraft", properties: command.properties}
]
case aggregate do
%Aggregate{created: false} ->
{:ok, new(aggregate, events), events}
%Aggregate{created: true, draft: nil} ->
{:ok, new(aggregate, events), events}
_ ->
{:error, aggregate, []}
# probably can replace the :error atom with a more useful error type
end
end
end
defmodule Test do
def create do
IO.puts "Test creating a draft from an empty aggregate"
draft = %Experiment{
config: %ExperimentConfig{
name: "NewExperiment"
}
}
command = %Command{
name: "CreateDraft",
properties: %{draft: draft}
}
a0 = Aggregate.new()
result = Aggregate.process(a0, command)
expected = {:ok,
%Aggregate{
created: true,
draft: %Experiment{config: %ExperimentConfig{name: "NewExperiment"}},
events: [
%Event{name: "CreateExperiment", properties: %{}},
%Event{
name: "CreateDraft",
properties: %{
draft: %Experiment{config: %ExperimentConfig{name: "NewExperiment"}}
}
}
],
published: nil
},
[
%Event{name: "CreateExperiment", properties: %{}},
%Event{
name: "CreateDraft",
properties: %{
draft: %Experiment{config: %ExperimentConfig{name: "NewExperiment"}}
}
}
]}
^expected = result
end
def new do
IO.puts "Test creating an aggregate from events"
events = [
%Event{ name: "CreateExperiment"},
%Event{ name: "Foo"}
]
a0 = Aggregate.new()
anext = Aggregate.new(a0, events)
expected = %Aggregate{
created: true,
draft: nil,
events: [
%Event{name: "CreateExperiment", properties: %{}},
%Event{name: "Foo", properties: %{}}
],
published: nil
}
^expected = anext
end
def update do
IO.puts "Test updating an aggregate's draft"
IO.puts " This is intentionally not implemented to demonstrate unexpected code failures."
draft = %Experiment{
config: %ExperimentConfig{
name: "UpdatedExperiment"
}
}
command = %Command{
name: "UpdateDraft",
properties: %{draft: draft}
}
a0 = Aggregate.new()
Aggregate.process(a0, command)
# no expectation set to show what happens when a process "crashes"
end
end
defmodule Tester do
def run(method) when is_function(method) do
tests = [
&Test.new/0,
&Test.update/0,
&Test.create/0,
]
method.(tests)
end
@doc """
Run all tests sequentially as separate Task processes.
Tasks that fail will not crash the caller.
"""
def supervised(tests) when is_list(tests) do
Enum.each(tests, fn test ->
{:ok, pid} = Task.Supervisor.start_link()
Task.Supervisor.async_nolink(pid, test)
|> Task.yield()
|> IO.inspect
IO.puts ""
end)
end
@doc """
Run all tests in the current process.
If one task fails the current process will stop.
"""
def unsupervised(tests) when is_list(tests) do
Enum.each(tests, fn test -> test.() end)
end
end
# Run tests in isolated processes
Tester.run(&Tester.supervised/1)
# Run tests in the current process
# (execution will stop when a failure is encountered)
# Tester.run(&Tester.unsupervised/1)
@rhomel
Copy link
Author

rhomel commented Sep 18, 2020

First Impressions of Elixir

I was interested in learning more about Elixir and Erlang after hearing about its fault tolerance capabilities.
So I used this week to sit down and learn it.

Why Elixir / Erlang

  • Core tech of BIG apps like Discord and WhatsApp
  • Fault tolerance potentially allows developers to focus on "happy path"
  • Immutable data avoids lots of error cases and makes testing easier
  • Impressive features like hot code reloading and automatic process restarts

Syntax

Elixir's syntax is inspired by Ruby's syntax. But that's about it. As far as concepts the language feels more like Erlang or Prolog.

Because the language is functional, you end up writing things more as you would in a Lisp or ML. However the pattern matching features of the language make it feel like Prolog at times. Despite this, the approachable syntax and examples fool you into adopting concepts like pattern matching. This is good because one of the things I struggle with when I learned Prolog was not fully understanding how I could apply pattern matching.

Elixir's syntax does have a lot of funny looking symbols. For example a map is defined with %{key: "value"}. From Perl or Ruby that looks familiar. But structs are also maps, so to create a struct you would do something like %CustomStructType{key: "value"}. There's also some odd feeling syntaxes you can apply to make pattern matching more useful. For example a module function argument can take an empty custom struct to match any struct of that type: def accept(struct = %MyStruct{}).

Documentation / Learning

If you've never used a functional or logic programming language, then Elixir might feel quite foreign. However many modern popular languages like Javascript do have functional concepts like map and filter so it won't feel impossible to do anything.

One key issue I had was the guide often leaves out details. It was enough to get started but you either have to consult the manual or look for other examples to get the full picture. This is especially true when attempting to use complex features like processes and supervisors.

Concepts

At a low level the features you do have access to are:

  • tuples, lists, maps, and structs (which are a special type of map)
  • modules of functions
  • pattern matching
  • guards
  • exceptions

Elixir does not have objects or mutable data. This is very important because if you try to work in a typical object-oriented manner, you will not get very far. What you can do is message passing between processes (also called the actor framework). But more comments about that later.

Elixir also is dynamically typed. Initially I thought this might be problematic, but guards and pattern matching can address this somewhat.

Pattern matching itself is not very useful but in Elixir it is used everywhere. So you find yourself using it to achieve other concepts like type safety. For example in the above code there is pattern matching to limit a specific function to an Event with name "CreateExperiment": def create(event = %Event{name: "CreateExperiment"}). This allows you to force a function to only match arguments that have the right "shape".

Though there isn't mutable data, you can "rebind" values. For example:

a = 1
a = 2
IO.puts a # prints 2
1 = 2 # error
a = 1 # error
# to force the left hand side to pattern match instead of rebind:
^a = 3 # pattern match error

For maps you can use the following syntax to generate a new map with the values replaced:

m = %{a: 1, b: 2}
n = %{m | b: 3} # results in %{a: 1, b: 3}
^m = n # pattern match error

Processes and Supervision

Unfortunately I just started to touch these features. They are more complex than I anticipated.

In a nutshell everything in Elixir/Erlang is a process. These processes are more like lightweight threads managed by the Erlang VM. Because data is immutable and everything runs in a process, a crashing process can easily be cleaned up and will not affect other running processes. This also gives Erlang/Elixir the ability to restart processes automatically for you.

How each process is linked is what creates complexity. The code I wrote above has a section to spawn each test as a separate process. However if you do so with the standard syntax, the processes are linked and when a child process "crashes" it crashes the entire process tree. So it is important to understand how this tree is constructed and which processes are linked to achieve your desired effect.

It is important to note that even with the the Elixir iex REPL, any syntax errors or "crashing processes" did not put the iex session into a weird state. For example if you use tools like a debugger, you often have to restart the program when your program crashes. In iex you can fix the code and reload it without stopping your session. I felt this was more useful than in other languages.

Another topic is message passing. Processes in Erlang communicate via message passing. This is how one can achieve Service Oriented Architecture (SOA) principles without having access to objects. Once you realize this you feel like perhaps objects are no longer necessary. This feature is very much a design decision of Erlang. This means message passing works even in a cluster of nodes.

Focusing on the "Happy Path"

In most other languages deciding how errors are handled is often the responsibility of the author. At a conceptual level this is understandable.

The problem is most developers do not have the experience or time to give error handling enough attention. And often in product development error cases are an after thought. So delegating error handling to the end developer is really asking them to solve a big problem they don't actually want to solve.

There's a concept of "let it crash" in Erlang/Elixir. This sounded very attractive and magical. The short answer is it isn't magic, but when used properly it can certainly save you a lot of mental effort.

One area where it saves you a lot of mental effort is focusing on coding only the "happy path". I was not able to fully test this in a short time but so far it seems like it could work.

For example even if we naively created a server that serviced requests. As long as each request was isolated in a separate process, any failing or even crashing process request will not affect any other executing process in the server. In Elixir and Erlang these features come out of the box and it enables you to do this by design as a default.

Doing this in other languages is possible but puts the full responsibility on the developer. For example in Go it is possible to catch panics and prevent your program from crashing due to unhandled panics. The problem however is now you must understand that if your program threw a panic you did not expect, can you guarantee the current state of the system is safe? If you designed your system to be stateless from the start and throughout, this might be ok. But if you made a mistake along the way or you have any kind of shared state that can get corrupted, then it may not be a good idea to keep the program running.

The caveat with languages like Elixir is that the complexity does not completely go away. As explained above you now need to consciously be aware of how your supervision tree is organized. Because linked processes crash their parents, you have to be careful about which processes you link together.

However when done properly, you can now focus on your program working correctly only for the input you expect. And then delegate the edge cases to Erlang because Erlang can return the system to a known good state.

But I probably need another week to explore this topic in more detail.

Tooling

I didn't have much time to look into this. But the REPL experience from iex is more useful than in other languages because of Elixir's design. You are encouraged to make short functions and small units so evaluating them in a REPL is much easier.

Feel

Elixir feels like a practical functional language. Most functional languages have a big academic or research push. You can tell this because the documentation often starts with abstract concepts. Compared to that Elixir felt like learning something practical or useful. The examples are geared towards telling you what you need to know instead of attempting to prove to you why these concepts are better.

Another point worth considering is teams like Discord have shown how to successfully do interop with other languages such as Rust. This is good because mutable state is one way to achieve higher performance when needed. Erlang VM performance isn't that bad however. One web benchmarks shows Go as 50% faster than Elixir. And Discord did say they scaled Elixir to 5 million concurrent users.

In this short introduction I didn't feel the tools like elixir or iex were slow. One thing that was a bit annoying are some of the cryptic feeling error messages. The errors were not impossible or unusable, but I felt like it was not as helpful as it could have been in certain cases.

Should we use Elixir?

I don't feel qualified at this point to answer that question.

But so far the language experience is very promising especially for backend web systems. I am definitely considering more personal projects in the language to explore it further.

I do feel like it can improve developer productivity significantly. Compared to verbose languages like Go and Java, Elixir makes very compact programs. I think this is an effect of the design decisions of Erlang.

Articles and Resources

@ggarek
Copy link

ggarek commented Sep 18, 2020

Nice work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment