Last active
September 18, 2020 10:31
-
-
Save rhomel/6b1f26e27915ffd8a85d1b5c94a59b8c to your computer and use it in GitHub Desktop.
Event Sourcing prototype with Elixir
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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
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
andfilter
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:
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:
For maps you can use the following syntax to generate a new map with the values replaced:
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 theiex
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. Iniex
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
oriex
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