Anybody who has worked with Elixir Phoenix in the past few years will likely be aware of something called “Contexts”. I won’t go into the detail of what contexts are in this guide, and recommend reading this to get information into what contexts are. However at a base level, contexts take a leaf out of the domain driven development guide, and propose grouping code by related functionality. I also will not go into too much depth about why domain driven development is good idea in this guide, there are several resources out there to explain this, however at a base level if done correctly, it can lead to easy to reason about code, that is loosely coupled and maps directly to the business domain.
However, there are situations that arise when developing code this way that can lead to problems, and specifically highly coupled code which can lead to difficulties in maintainance, and also performance issues in some circumstances, in this post I will try and give an explanation of this problem, and how to mitigate against it.
I find that is always best to give concrete examples to illustrate a problem and solution, and to do this I will take the area I personally work in, which is FinTech, specifically company/employee expenses.
At a basic level, companies likely have employees who make expenses which often have to be paid by the company. Historically this has been done using a shared card, of the employee paying with their own money and being reimbursed in their next payslip. This isn’t a great experience, so in this example system, we want to a system where companies can be created, along with card holders (employees) and expenses can be stored. And the expenses can either be set as a company expense, or personal expense based on what the purchase was.
To illustrate this example I have created a project called “PayStation” which you can see here.
In this example, the architecture can be formalised as 2 Phoenix Contexts which are as follows
The major problem here is how we keep this cohesive. As we now know the “Payments” context gets transactions from a payment processor and parses them into our system, however this means we need to create expenses off the back of these transactions. However its a bad design decision to have the “Payments” context call the “Expenses” context for the reasons explained earlier. It is not a problem for “Expenses” to call “Payments”, becauses “Expenses” implies transactions, but “Payments” do not imply expenses. If we ended up coupling these context the following would happen
As seen above, we can see that there is tight coupling between the payments and expenses contexts, however what we desire is this
How can we solve this since we need to be aware of transactions being created in the “Payments” context within the “Expenses” context?
A great way is solving this using message queues which uses the “Publish Subscribe” pattern. In so far as the “Payments” context can publish when a transaction has occurred, and other contexts can subscribe to that event, without creating coupling within the “Payments” context. This way if our business cases change or extend in the future, we can easily adapt this by creating additional subscribers etc without touching the “Payments” domain, which in many ways gives us the OCP principle (open closed).
There are no hard a fast rules on what queue you should use. In the example project I use Kafka, however others are available such as RabbitMQ, Google PubSub etc. However I would highly recommend using an “at least” once queue, this means that it tracks if a consumer has processed a message or not and mantains offsets for this, this is great because it means your system will not miss messages.
One of the reasons I point this out is you might think “could I use the PubSub mechanism built into Phoenix?”. Whilst you could do this, and you would achieve the same desired effect from a design perspective, it might not be the most robust way. Mainly because it is an “at most once” system, which means if your system crashes when processing messages, it will not continue processing those messages when it recovers which could be very bad.
The repo will give you the full picture of how this works, however here are some key highlights, starting with the architecture.
As you can see in the repo we have a “Payments” folder within the “pay_station” directory. This groups all “Payments” based context logic. We have created a “fake” PaymentProcessor
as follows
defmodule PayStation.Payments.External.PaymentProcessor do
alias PayStation.Payments.External.Transaction
def fetch_payments do
0..Faker.random_between(0, 100)
|> Enum.map(fn _seed ->
%Transaction{
id: Faker.UUID.v4(),
quantity: Faker.random_between(0, 1_000_000),
card_id: Faker.random_between(1, 10),
merchant: Faker.Company.name
}
end)
end
end
This is used to simulate the creation of payments, imagine this being an external payments processor.
We then have a “GenServer” which runs on a 10 second schedule to fetch outstanding payments made, when it retrieves these, it parses them, and puts them on the Kafka queue, the bulk of this can be seen in this function
@impl true
def handle_info(:work, state) do
transactions =
PaymentProcessor.fetch_payments()
|> Enum.map(fn transaction -> {transaction.id, Jason.encode!(transaction)} end)
Kaffe.Producer.produce_sync("transactions_created", transactions)
# Reschedule once more
schedule_work()
{:noreply, state}
end
Similar to the Payments context we can see we have an “expenses” folder which groups the related expenses logic. In this we have a few Ecto models representing companies, card holders and expenses. However we also have an expenses_processor
which receives the Kafka messages as follows
def handle_messages(messages) do
new_expenses =
messages
|> Enum.map(& &1.value)
|> Enum.map(& Jason.decode!(&1, as: %Transaction{}))
|> Enum.map(fn transaction ->
%{
transaction_id: Map.get(transaction, "id"),
quantity: Map.get(transaction, "quantity"),
merchant: Map.get(transaction, "merchant"),
card_holder_id: Map.get(transaction, "card_id"),
inserted_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second),
updated_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
}
end)
Repo.insert_all(Expense, new_expenses)
:ok
end
This is done using Kaffe and the initialisation can be seen in config.ex
. This receives Kafka messages for the transactions whenever they are created via the Kafka message queue. This means we can process transactions without creating Payments context coupling.
Finally to demonstrate the logic further, we have an expenses_controller.ex
which allows expenses to be fetched for a company etc, and also be reviewed by somebody, this is done without any coupling to the “Payment” context.
Firstly, I want to point out that this is quite a crude example used to demonstrate a business case using contexts, and how to use a message queue to solve coupling issues. This by no case means its production ready code, to make this production ready you would need to take into account several things such as
Finally, I want to acknowledge and point out that of course including a fully fledged message queue such as Kafka creates an overhead of infrastructure maintainance. Although this is getting easier with managed services, to the point where you can even use Kafka in Heroku now, its important for you to balanace the complexity of the project, vs the need for such tools. However its important to note that, Phoenix is often considered a break away from the microservice pattern to a monolith pattern, this is for 2 reasons, one is due to its roots in the Ruby community and the “Majestic Monolith” pattern, but also because of the sheer performance cababilities of Elixir with OTP. Whilst this is understandable, its important for us to learn from the microservice patterns and what they solve, as it can apply to Elixir monoliths aswell as we have just seen. Using something like Kafka also means if you did choose to split your Elixir services, you will still retain the benefits as it can be used for cross service communication.
Happy coding!