Event-driven architecture is a powerful idea. It allows the system to grow and evolve in a loosely coupled manner. The concept of separating a business fact from its secondary consequences brings a lot of flexibility to the table and allows teams to work efficiently even in a substantially complex environment.
At Appunite, we use it a lot these days. But every coin has two sides. One thing that you need to tackle among others, when you decide to embrace that approach, is the message delivery guarantee. In terms of that, you can pick at most once delivery or at least once delivery.
The former doesn’t ensure that a message will be delivered at all, but it also doesn’t produce duplicates. On the other hand, delivery of the latter is guaranteed, but you need to cope with processing the same message many times. Practically, in most business domains, we can’t afford to lose a message. For instance, can you imagine that a banking system would lose a message concerning your paycheck? You probably would not use it again. Thus, in most cases, at least once delivery is the way to go.
But then… how can we deal with these duplicates? Receiving a paycheck twice wouldn’t be a bad thing, but being charged twice for your Netflix subscription is definitely not cool! In this blog post, I want to share with you a few typical use-cases along with possible solutions. We are going to use that "Netflix-like-system" example as the groundwork for our discussion. So, let's find out what challenges we could expect in such a domain.
Rolling up our sleeves
Following the terrifying example with a single subscription being charged several times, let’s sketch out a technical overview. Imagine that this requirement is being fulfilled in the system by two contexts, which cooperate. Subscriptions, which is responsible for holding subscription details, recurring logic and so on, and Billing which takes care about payment methods and processing billing entries. When a subscription is prolonged for the next period, Subscriptions context publishes an event:
%Event.SubscriptionProlonged{
event_id: "66877e92-aeec-4490-9fd8-b69546797c16",
subscription_id: "ad5a6c02-819d-4649-b364-e236c2cdd5b7",
product_id: "90a501c6-535e-4495-931e-2b38fc761417",
subscriber_id: "b605825e-5c9e-49ff-be2f-81226c2a1132",
months_prolonged: 1,
ends_at: ~U[2021-06-09 14:15:22.446671Z]
}
After that instance has taken place, the system needs to charge an adequate fee corresponding to the chosen product and given period. How can we achieve that without draining our dear users’ bank accounts to zero?
Assuming that you have a RDBMS under the hood, you can leverage an unique index. It prevents you from inserting an already existing record. What is worth noting is that the condition is evaluated at the database level, so the code is safe in terms of concurrent access.
So, probably, we would have some code that will be responsible for receiving an event and passing it down to the business models. It could look like this:
defmodule Billing.Entry do
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
schema "billing_entries" do
field :payer_id, :binary_id
field :amount_cents, :integer
timestamps()
end
end
defmodule Billing.Consumer do
# Event subscription logic skipped
@interested_in [Event.SubscriptionProlonged]
def handle_event(%Event.SubscriptionProlonged{} = e) do
Billing.Service.register_entry(
e.event_id,
e.subscriber_id,
e.product_id,
e.months_prolonged
)
end
end
defmodule Billing.Service do
# Arguments validation skipped for the sake of simplicity
def register_entry(entry_id, payer_id, product_id, months_billed) do
amount_billed = Billing.Pricing.calculate(product_id, months_billed)
billing_entry = %Billing.Entry{
id: entry_id,
payer_id: payer_id,
amount: amount_billed
}
{:ok, _} = Repo.insert(
billing_entry,
on_conflict: :nothing,
conflict_target: :id
)
:ok
end
end
Relevance of the idempotency
As you can see the Billing.Service.register_entry/4
always returns atom :ok
. No matter how many times we invoke it, the result will be recorded only once. This trait is called idempotency. It's naturally connected with read operations (asking the question should not change the answer), but thanks to setting a proper unique index, we can also achieve it on the write side. In this case, the puzzle was about choosing proper data for a primary key, event_id is exactly what we need here. And we can see that it gets along well with our previous decision - picking at least once guarantee.
Speaking of idempotency of writes. It is an important quality of the Billing.Service
. We should highlight this trait and leave some information about it in the code. A unit test would be a good place for that.
defmodule Billing.ServiceTest do
use Repo.DataCase
import Ecto.Query
test "registers entry idempotently" do
entry_id = random_uuid()
payer_id = random_uuid()
product_id = random_uuid()
months_billed = 1
subject = fn ->
Billing.Service.register_entry(
entry_id,
payer_id,
product_id,
months_billed
)
end
assert :ok = subject.()
assert :ok = subject.()
assert [%Billing.Entry{
id: ^entry_id
payer_id: ^payer_id
}] = Billing.Entry |> where([e], e.id == ^entry_id) |> Repo.all()
end
end
Summary
So, let's quickly sum up what we have achieved here. We have implemented a specific business scenario, which spans across two possibly separate parts of software, in the way that respects their autonomy. We can easily imagine that these two parts can be deployed as separate systems. We have leveraged the RDBMS built-in mechanism in order to provide idempotency at our interface and ensure that we won't violate any business requirements. This certainty has also been materialized in the test case.
Of course, integrating parts of a software asynchronously with events is a big matter. Stay tuned and expect deeper exploration with slippery cases in the future!
Did you find the article interesting and helpful? Take a look at our Elixir page to find out more!
#language #programming #framework