Update post

This commit is contained in:
JasterV 2025-06-04 00:50:54 +02:00
parent 922467e815
commit e3dd5dae1e

View file

@ -5,8 +5,6 @@ pubDate: 2025-06-02
image: "./assets/elixir.svg"
---
# TLDR
In this post I'd like to talk about an interesting way to decouple GenServers from the rest of the system by using a PubSub library.
We will do it by going through actual code. We will take a look at a regular Elixir application and we will discuss possible problems in it.
@ -19,7 +17,7 @@ The project we will look at is very simple.
It consists of an IoT application that automates locking and unlocking a door.
A user can interact will perhaps interact with our application using some sort of remote control.
We can imagine a user interacting with our application using some sort of remote control.
Then, when the door gets locked, we want a light to become red and a notification to be sent to us.
Finally, when the door gets unlocked, we want a light to become green and get notified too.
@ -29,8 +27,8 @@ Finally, when the door gets unlocked, we want a light to become green and get no
The application consists of:
- A GenServer that manages the state of the Door and is responsible for locking or unlocking it.
- A GenServer that manages the state of the lights and is responsible for changing colors.
- A GenServer that manages notifications.
- A module that manages the state of the lights and is responsible for changing colors.
- A module that manages notifications.
Let's look at a diagram of the whole system first:
@ -44,9 +42,9 @@ user {
}
firmware {
door_server: Door Server
light_server: Light Server
notifications_server: Notifications Server
door_server: DoorServer
lights: Lights
notifications: Notifications
label.near: bottom-left
}
@ -60,11 +58,11 @@ hardware: {
}
user -> firmware.door_server: lock door {style.animated: true}
firmware.door_server -> firmware.light_server: set_lights_red {style.animated: true}
firmware.door_server -> firmware.notifications_server: send_notification {style.animated: true}
firmware.door_server -> firmware.lights: set_lights_red {style.animated: true}
firmware.door_server -> firmware.notifications: send_notification {style.animated: true}
firmware.door_server -> hardware: lock {style.animated: true}
firmware.light_server -> hardware: set_lights_red {style.animated: true}
firmware.notifications_server -> third_party_notifications_service: send_notification {style.animated: true}
firmware.lights -> hardware: set_lights_red {style.animated: true}
firmware.notifications -> third_party_notifications_service: send_notification {style.animated: true}
```
</div>
@ -72,22 +70,391 @@ In this post we will focus on the firmware layer, more specifically in the Door
## Let's get into code
### Door GenServer
Let's go directly to the point, this is how we could implement a basic version of a DoorServer
```elixir
defmodule DoorAutomation.DoorServer do
use GenServer
@locked_state :locked
@unlocked_state :unlocked
alias DoorAutomation.Lights
alias DoorAutomation.Notifications
# Client API
def start_link(initial_state \\ :locked) do
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
end
def lock do
GenServer.call(__MODULE__, :lock)
end
def unlock do
GenServer.call(__MODULE__, :unlock)
end
def get_state do
GenServer.call(__MODULE__, :get_state)
end
# Server API
@impl true
def init(initial_state), do: {:ok, initial_state}
@impl true
def handle_call(:get_state, _from, current_state) do
{:reply, current_state, current_state}
end
@impl true
def handle_call(:lock, _from, _current_state) do
# Call the underlying hardware
# Hardware.unlock_door()
Lights.set_red()
Notifications.send_notification("Door has been Locked.")
{:reply, :locked, @locked_state}
end
@impl true
def handle_call(:unlock, _from, _current_state) do
# Call the underlying hardware
# Hardware.unlock_door()
Lights.set_green()
Notifications.send_notification("Door has been Unlocked.")
{:reply, :unlocked, @unlocked_state}
end
end
```
For the purpose of this article, the underlying calls that the Door Server would need to make to the Hardware are left out of the code.
### Let's write some tests
Let's now see a basic test suite for the DoorServer:
```elixir
defmodule DoorAutomation.DoorServerTest do
use ExUnit.Case
alias DoorAutomation.DoorServer
alias DoorAutomation.Lights
alias DoorAutomation.Notifications
setup do
# Here we'd add whatever setup is needed
# for the lights and notifications module
{:ok, _} = start_supervised({DoorServer, :unlocked})
:ok
end
test "locking the door changes state, light, and sends notification" do
assert DoorServer.get_state() == :unlocked
assert :ok = DoorServer.lock()
assert DoorServer.get_state() == :locked
assert Lights.get_color() == "red"
assert Notifications.get_notifications() == ["Door has been Locked."]
end
test "unlocking the door changes state, light, and sends notification" do
assert :ok = DoorServer.lock()
assert DoorServer.get_state() == :locked
Notifications.reset_notifications()
assert :ok = DoorServer.unlock()
assert DoorServer.get_state() == :unlocked
assert Lights.get_color() == "green"
assert Notifications.get_notifications() == ["Door has been Unlocked."]
end
end
```
## What is wrong here?
Possible problems:
Well, first of all I'd like to make it clear that there is actually nothing "wrong". The code we see above is completely valid.
- Coupling
It compiles and it could be enough for someone's needs.
## How can we improve it?
With that said, let's talk about some possible issues we could run into.
- Decouple the Door server by making it emit an event when the door is locked or unlocked
### Coupling!
Following the previous design, we are coupling the DoorServer implementation with the lights and notifications modules.
The lights module might rely hardware which could fail at any time (perhaps the circuits break) and the notifications module might rely on a network to send messages.
If the lights are faulty or the network goes down, the door could potentially stop working and the user wouldn't be able to lock or unlock it anymore.
In this case, if a user locks the door but the lights don't work or the notifications service is off, we want the door to lock anyway.
Ideally, any issues that might occur related to the lights or notifications system should be handled in their own modules.
Our door server should simply worry about locking or unlocking a door.
Coupling these modules spreads complexity that should be isolated.
### Coupling in tests
Coupling doesn't stop in the DoorServer module, it get's propagated into our tests.
Because our `lock` and `unlock` functions cause a series of side effects, we find ourselves having to add extra setup to test them.
Now, what was supposed to be a unit test suite for our DoorServer ends up looking more like an integration test suite.
Each time we add a new side effect that doesn't necessarily have to do with locking or unlocking a door, these tests have to be updated.
## So, what can we do?
We want to find a way to decouple our door server from the rest of the system and protect it from unrelated issues.
I'm sure there are many ways you could do this, but in this article I want to talk about a specific one I recently deployed to production with success.
Let's make our Elixir application more event-driven!
We can make the DoorServer publish an event whenever the doors are successfully locked or unlocked.
Imagine if we had some sort of dedicated mailbox for our door events that any processes can subscribe to.
Then, if a module that is interested on those events can simply subscribe to them and implement their own handlers.
This automatically makes our application more flexible, testable and maintainable.
## Introducing Phoenix PubSub
[Phoenix PubSub](https://github.com/phoenixframework/phoenix_pubsub) is a known Elixir library that comes with the Phoenix framework.
It is a generic PubSub library which enables us to implement a publish-subscribe pattern in our Elixir applications.
It supports distribution by default (pusblishing and subscribing between erlang nodes) and multiple backends including Redis.
Eventhough it explicitly says it is made for the Phoenix framework, it doesn't have any phoenix-related dependencies and can be used on its own.
Also we know Phoenix has been battle tested, so we can be sure Phoenix PubSub too!
## Rewriting the DoorServer
As mentioned, we'll rewrite the DoorServer by making it publish events and remove any coupling with the other modules:
```elixir
defmodule DoorAutomation.DoorServer do
use GenServer
alias Phoenix.PubSub
@locked_state :locked
@unlocked_state :unlocked
@door_topic "door_events"
# Client API
def start_link(initial_state \\ :locked) do
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
end
def lock do
GenServer.call(__MODULE__, :lock)
end
def unlock do
GenServer.call(__MODULE__, :unlock)
end
def get_state do
GenServer.call(__MODULE__, :get_state)
end
def events_topic(), do: @door_topic
# Server API
@impl true
def init(initial_state) do
{:ok, initial_state}
end
@impl true
def handle_call(:get_state, _from, current_state) do
{:reply, current_state, current_state}
end
@impl true
def handle_call(:lock, _from, _current_state) do
# Hardware.lock_door()
Phoenix.PubSub.broadcast(DoorAutomation.PubSub, @door_topic, :door_locked)
{:reply, :locked, @locked_state}
end
@impl true
def handle_call(:unlock, _from, _current_state) do
# Hardware.unlock_door()
Phoenix.PubSub.broadcast(DoorAutomation.PubSub, @door_topic, :door_unlocked)
{:reply, :unlocked, @unlocked_state}
end
end
```
I'd like to also show how to write a subscriber for the lights module to react to pub sub events.
Also, to be consistent with our new design, we'll make it publish events when the lights are updated.
This way testing that the lights were updated after firing a door event becomes simpler as we'll see later.
```elixir
defmodule DoorAutomation.LightsSubscriber do
use GenServer
alias Phoenix.PubSub
@lights_topic "lights_events"
# Client API
def start_link do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def events_topic(), do: @lights_topic
# Server API
@impl true
def init(_args) do
# Subscribe to the topic when the GenServer starts
:ok = Phoenix.PubSub.subscribe(DoorAutomation.PubSub, DoorAutomation.DoorServer.events_topic())
{:ok, %{}}
end
@impl true
def handle_info(:door_locked, state) do
:ok = DoorAutomation.Lights.set_red()
Phoenix.PubSub.broadcast(DoorAutomation.PubSub, @lights_topic, :lights_set_red)
{:noreply, state}
end
@impl true
def handle_info(:door_unlocked, state) do
:ok = DoorAutomation.Lights.set_green()
Phoenix.PubSub.broadcast(DoorAutomation.PubSub, @lights_topic, :lights_set_green)
{:noreply, state}
end
end
```
As a note, some people might prefer publishing the light events from inside the Lights module,
I don't have a strong opinion here so for simplicity I'll leave that logic in the LightsSubscriber.
## Rewriting our tests
Now we can rewrite our tests in a more event-driven way:
```elixir
defmodule DoorAutomation.DoorServerTest do
use ExUnit.Case
alias DoorAutomation.DoorServer
alias Phoenix.PubSub
setup do
# Subscribe the current test process to the door events topic
# This makes events published by DoorServer arrive in the test process's mailbox.
:ok = PubSub.subscribe(DoorAutomation.PubSub, DoorAutomation.DoorServer.events_topic())
{:ok, _} = start_supervised({DoorServer, :unlocked})
:ok
end
test "locking the door changes state and publishes :door_locked event" do
assert :ok = DoorServer.lock()
assert DoorServer.get_state() == :locked
assert_receive :door_locked
refute_receive :door_unlocked
end
test "unlocking the door changes state and publishes :door_unlocked event" do
assert :ok = DoorServer.unlock()
assert DoorServer.get_state() == :unlocked
assert_receive :door_unlocked
refute_receive :door_locked
end
end
```
Now to write tests for the subscriber we can do as follows:
```elixir
defmodule DoorAutomation.LightsSubscriberTest do
use ExUnit.Case
alias DoorAutomation.LightsSubscriber
alias DoorAutomation.DoorServer
alias Phoenix.PubSub
# Setup block that runs before each test
setup do
:ok = PubSub.subscribe(DoorAutomation.PubSub, LightsSubscriber.events_topic())
{:ok, _subscriber_pid} = start_supervised(LightsSubscriber)
:ok
end
test "LightsSubscriber subscribes sets lights red on :door_locked" do
PubSub.broadcast(DoorAutomation.PubSub, DoorServer.events_topic(), :door_locked)
assert_receive :lights_set_red
refute_receive :lights_set_green
end
test "LightsSubscriber sets lights green on :door_unlocked" do
PubSub.broadcast(DoorAutomation.PubSub, DoorServer.events_topic(), :door_unlocked)
assert_receive :lights_set_green
refute_receive :lights_set_red
end
end
```
## What has improved?
By refactoring our DoorServer this way we only have to worry about maintaining and testing logic that concerns exclusively the door and the events it is meant to publish.
On the other hand, other modules can be tested very easily if we also refactor them to work in a more event-driven way.
For instance, as we just saw, to test if the lights change when a door change occurs we can use entirely our PubSub library to simulate door events happening and to assert that new events are published.
## Conclusions
Decoupling your elixir application components by making them more event-driven can make them more maintainable,
allowing them to grow independently of the rest of the system being sure you won't be breaking other modules as long as you don't break your events contract.
We've also seen how tests become simpler due to the fact that each test suite becomes more narrowed in scope.
There are some drawbacks though:
- Debugging becomes harder.
- You are introducing an extra of asynchronous broadcast messaging and therefore your elixir processes become harder to debug.
- Event delivery is not ensured.
- If a subscriber goes down, Phoenix PubSub won't try to re-deliver lost events once the process restarts.
- Depending on your situation you might want to implement a more complex pub-sub system that does its best to ensure event delivery.
- You loose orchestration
- You can't enforce the order each subscriber will consume the same event.
If your case requires one side effect to happen before another
(For example, the lights have to turn red before sending a notification) you will need to setup a single subscriber that executes them in order.
So as with every software pattern, depending on the requirements of your system this might or might not work for you.
In my case, my application didn't require side effects to be performed in order nor it was a problem if an event was lost because a process was down.
This is the first ever post that I publish online, I hope someone finds it useful, thanks for the read!