Building a simple shop application

Hi! I’m Ruby developer that was invited to work with Elixir. The company gave me a task to build a shop application (actually it’s not a shop application - I changed the task to not share the real one):

Functions are:

- create_customer(name, amount_of_money)
- buy_product(customer_name, product)
- return_product(customer_name, product)
- ...

Requirments:

- Usual OTP app with data in memory without any databases or disk storage
- The system should handle 5 or fewer operations for one customer in a single moment. If there are more than 5 operations in a pending state then an error should be returned
- The system should able to work with several customers simultaneously without performance impact on each other

I’ve read the official introduction and most of the https://codestool.coding-gnome.com/courses/take/elixir-for-programmers/ course but still don’t really understand how to build the app.

For every customer, I want to make a process that holds an amount of money.

What I don’t understand:

  • How to make tests to check that there is an error message if there are more than 5 operations? I can spawn 6 processes but they run fast - will it be correct?
  • How to track how many processes use a customer? In a Customer server with GenServer hooks?
  • Should I use Supervisor? If a customer process dies then all the data will be lost
  • Should a customer be in a separate node or just using GenServer is enough?

How you can help me:

  • Suggest me a book/article/course that can explain the topic better (I’m thinking about “Elixir in Action”)
  • Suggest me simpler projects that I can make on my own before doing this one
1 Like

Hey! Sounds like you are building a cart.

I didn’t quite understand the requirement about 5 operations in a single moment. Could you provide a specific example?

From your description it looks like you may need to look into GenServer, which will hold your cart state.

I am not quite sure what operations mean? Could you provide an example? Here is the approach I would consider. In scenario described I would consider looking into event sourcing. So, you would have a command, for example create_customer and that will produce an event → CustomerCreated or CustomerCreationFailed. Maybe that’s not the best naming, but hope you get the gist. Events could be structs which would contain more details about event, for example it can contain username, etc… When working with events, you would need to create a pubsub, a system which allows publishing/subscribing to particular events. Then in your tests you can subsribe to events produced and check that you receive events you expect in particular scenario. I believe you would use something like assert_receive in your tests. Pub/sub helps with decoupling as well.

Not sure what that question means? Could you provide an example?

Supervisor will restart failing processes, but it will be a clear state. That means that you will loose your data anyways. The data will be lost for a particular GenServer. If I understand scenario described correctly, you need a shopping cart per customer. When customer1 process dies, the data would be lost for that customer1, but not for any other customers. If you need to restart failing process, then Supervisor can take care of that for you.

I would say start with GenServer. There is a way to create your system database less by keeping data duplicated across nodes, but I would not go there to begin with. Possibly in the future, but judging from described problem, I don’t think you would need to go there at all.

Elixir in Action is a good book. Francesco Cesarini book “Designing for Scalability with Erlang/OTP” contains a lot of goodness, but uses Erlang to expain examples and it’s not small book :slight_smile: Also, you can read up on GenServers and Supervisors from the documentation and see how it’s used GenServer — Elixir v1.16.0
In this talk Chris solves similar problem to yours https://www.youtube.com/watch?v=fkDhU-2NWJ8

P.S. Hope that helps!

1 Like

I understand this item of the task like this: I’m building a cart service. This service will accept many requests (buy_product, return_product) from other processes. These requests will be in a queue. A queue can be quite long so we want to restrict the maximum number of operations for one customer.

Operations - public functions (API) from the module I should build. Agents use the term operation.

My question is about performance requirement of maximum 5 operations per customer.

Some Erlang would be good to fully understand what’s happening under the hood.


Thank you for all the suggestions.

From your response I see that the task I have is not really transparent.

You can definitively make the limit work with genservers, for instance this:

defmodule The_module do
  use GenServer

  def create(customer_id) do
    case GenServer.whereis(ref(customer_id)) do
      nil ->
        Supervisor.start_child(Customer.Supervisor, [customer_id])
      pid ->
        {:ok, pid}
    end
  end

  def start_link(customer_id) do
    GenServer.start_link(__MODULE__, customer_id, name: ref(customer_id))
  end

  def init(_customer_id) do
      {:ok, 0}
  end

  def execute_task(customer_id, task) do
    case try_call(customer_id, :can_task) do
       {:error, _} = error -> error
       {:ok, _, pid} -> GenServer.cast(pid, task)
       error -> error
    end
  end

  def handle_call(_, _from, 5), do: {:reply, {:error, "Running max tasks"}, 5}
  def handle_call(:can_task, _from, tasks), do: {:reply, {:ok, tasks, self()}, tasks}

  def handle_cast(:decrease, n) when n > 1 do: {:noreply, n-1}
  def handle_cast(:decrease, _), do: {:stop, :normal, 0}

  def handle_cast(_, 5), do: {:error, "Running max tasks"}

  def handle_cast({:a_task, stuff}, n) do
    # do things
    {:noreply, n+1}
  end

  defp ref(customer_id) do
    {:global, {:customer, to_string(customer_id)}}
  end

  defp try_call(customer_id, call_function) do
    case GenServer.whereis(ref(customer_id)) do
      nil ->
        case create(customer_id) do
          {:ok, pid} ->
            GenServer.call(pid, call_function)
          {:error, {:already_started, pid}} ->
            GenServer.call(pid, call_function)
          error ->
            error
        end
      pid ->
        GenServer.call(pid, call_function)
    end
  end

  defp try_cast(customer_id, cast_function) do
    case GenServer.whereis(ref(customer_id)) do
      nil ->
        # return error or ignore if meaningless, or start a genserver for this customer
        {:error, "Error: no pid when trying cast: #{cast_function}"}
      pid ->
        GenServer.cast(pid, cast_function)
    end
  end

Would allow you to do from anywhere:

The_module.execute_task(customer_id, {:a_task, stuff})

It’s not exactly guaranteed the order of the tasks (but in most cases it can be assumed) but it’s guaranteed that no more than 5 will be allowed as long as you always pass them through this genserver.
At the end of each task you would need to call The_module.try_cast(customer_id, :decrease)

This is a naive approach, the ordering of the handle_ functions matter, and it relies on the fact that a genserver will process the messages in its inbox in guaranteed order. It also relies on the fact that a cast returns immediately a new state so that the processing can take as long as required, while the state (task count) is immediately updated. You can also use ETS tables, and store something like {customer_id, number_of_tasks} and then use ets lookups to figure out what to do.