← Back to list

Beyond Raw Processes: Building The Shopping Cart with Elixir GenServer

This article is a sequel to this. I will advise you to take a look at it before reading this one.

Andrew (Nature) Okoye · 2026-05-18 06:33 · 1 claps · 8.2 min read
#elixir #distributed-systems #concurrency #genserver #software-engineering
Open on Medium ↗
Wiki topics: 📚 · Books & Reading

Beyond Raw Processes: Building The Shopping Cart with Elixir GenServer

Shopping Cart implemented with Elixir GenServer

Shopping Cart implemented with Elixir GenServer

This article is a sequel to this. I will advise you to take a look at it before reading this one.

In our previous article, we built a shopping cart system using raw Elixir processes. We manually managed state through recursive function calls, pattern-matched every incoming message, and carefully threaded state through each iteration. It worked, but it felt like building a car engine from scratch when what we really wanted was to drive to the store. In this article, we will see how to address similar problems with Elixir GenServers — Elixir’s battle-tested abstraction that handles all the process boilerplate while adding features such as supervision, debugging tools, and hot code reloading. While Elixir GenServer is an amazing feature, certain problems make it a bad idea to use — we will also look at this in this article, including when it's a good time to use GenServers.

What is Elixir GenServer

Elixir GenServer is an OTP behaviour that runs in its own process, allowing developers to implement concurrency in an Elixir application. OTP (Open Telecom Platform) is a set of libraries and design principles built into Erlang. GenServer is one of those libraries — specifically designed to handle concurrency using a well-defined structure called a behaviour. A GenServer module is expected to have certain functions or callbacks, namely: init, handle_call and handle_cast.

These Server Callbacks (init/1, handle_call/3, and handle_cast/2) are never called directly by your application’s business logic. Instead, the GenServer process invokes them internally. Because these functions are “internal” to the process’s operation, it’s tempting to think they should be defined as private functions using defp. ​However, in Elixir, callbacks must be public (def). This is because the GenServer module (part of the OTP library) lives outside your module and needs permission to “reach in” and trigger those functions. While you won’t see professional code using defp for callbacks, you will see the @impl true attribute used above them. This signals to both the compiler and other developers that these functions are fulfilling the GenServer contract and are not meant to be called directly by the rest of your app.

While the callbacks follow a strict, predefined structure, the Client Functions are completely flexible. These are the public-facing functions that other parts of your application interact with. ​The primary job of the client functions is to take a request from the user, wrap it up, and use GenServer.call/3 (for synchronous requests) or GenServer.cast/2 (for asynchronous ones) to send that message to the GenServer process

Elixir GenServer Breakdown(The Shopping Cart)

In the previous article, we implemented a shopping cart system using raw processes and send & receive functions. Let us rebuild it using GenServer while breaking down how it works. Let us start by reimplementing the ShoppingCart with GenServer. Consider the snippet below:

defmodule ShoppingCartServer do
  # Every module that is implemented as a GenServer must have this line
  # It injects GenServer functionality right into your module
  use GenServer

  require Logger # require Logger for logging purposes

  # -------------------------
  # Client API - Set of public facing functions that exposes functions that allows 
  # the GenServer process to be spawned and messages passed to these GenServer processes
  # -------------------------

  def start_link(item_lookup_fn) do
    # Starts and links the GenServer to the caller process
    GenServer.start_link(
      __MODULE__, # Name of the module that GenServer is implemented on. In this case, __MODULE__ == :ShoppingCartServer
      %{
        cart: %{},
        total: 0,
        lookup: item_lookup_fn
      } # This is the initial state of the GenServer
    )
  end

  def pick_item(cart_pid, item, qty) do
    # Note how we were passing PID to the GenServer's call function and the message which is a tuple of three items
    GenServer.call(cart_pid, {:pick_item, item, qty})
  end

  def total(cart_pid) do
    # In this case, the message is simple an atom called total
    GenServer.call(cart_pid, :total)
  end

  def get_cart(cart_pid) do
    # In this case, the message is get_cart
    GenServer.call(cart_pid, :get_cart)
  end

  # -------------------------
  # Server Callbacks -- These functions are not to be called from outside the module. However,
  # they must be implemented and must follow a definite contract which specifies.
  # the exact function name and arity (e.g., init/1, handle_call/3) and
  # structure of the return value (e.g., {:ok, state}, {:reply, reply, state})
  # -------------------------

  @impl true
  def init(state) do
    # This is the GenServer callback that is called when the client function (start_link) calls GenServer.start_link.
    # It returns a tuple whose first item is an ok atom and its second is the initial state passed from the client function
    {:ok, state}
  end

  @impl true
  def handle_call({:pick_item, item, qty}, _from, state) do
    case state.lookup.(item) do
      nil ->
        {:reply, {:error, :item_not_found}, state}

      item_pid ->
        case ItemServer.reserve(item_pid, qty) do
          {:ok, %{item: item_name, qty: reserved_qty, unit_price: price}} ->
            updated_cart =
              Map.update(
                state.cart,
                item_name,
                reserved_qty,
                &(&1 + reserved_qty)
              )

            updated_total =
              state.total + reserved_qty * price

            new_state = %{
              state
              | cart: updated_cart,
                total: updated_total
            }

            {:reply, :ok, new_state}

          {:error, :insufficient_stock, _item, available} ->
            Logger.error("Only #{available} items left")

            {:reply,
             {:error, :insufficient_stock}, state}
        end
    end
  end

  @impl true
  def handle_call(:total, _from, state) do
    {:reply, state.total, state}
  end

  @impl true
  def handle_call(:get_cart, _from, state) do
    {:reply, state.cart, state}
  end
end

The snippet above defined a GenServer module that holds the state of the cart. Note that there is more than one “handle_call” function, and all have an arity of 3, and they all return a tuple of 3 items, with the first item of the tuple having a value whose data type is an atom (:reply)The second item of the tuple is what is returned to the caller, while the last item of the tuple is the updated state of the GenServer. The handle_call/3 callback has three arguments. The first one is the message sent via the GenServer.call/2 function from the client API. Elixir knows which of the handle_call functions should be executed by pattern matching the message sent from the GenServer.call function; the second argument holds the PID of the process that sent the request. Most of the time, this argument is ignored because handle_call functions are used for synchronous operations. This means that we immediately get a response to the caller in handle_call. However, if there will be an asynchronous operation that will happen in handle_call and you need the response from this asynchronous operation, this is where the from argument can be really useful. Because a GenServer processes messages sequentially, long-running work inside handle_call/3 can block other messages from being processed. In situations like this, you can delegate the expensive work to another process using Task and later reply manually with GenServer.reply(from, {:ok, result})when the result is ready.

There is a popular misconception that handle_call is strictly for synchronous operations while handle_cast is for asynchronous ones. This is only half true. The real distinction comes down to whether you care about the response. handle_cast is a pure 'fire-and-forget' tool—you trigger it and immediately move on without waiting for a result. On the other side, handle_call is always synchronous for the caller. The client process will literally freeze and wait on the line until it gets a reply. The important point to note here is that while the client is blocked, the GenServer itself can actually delegate the heavy lifting to a background task via Taskand reply later via Genserver.reply/2, but your caller isn't going anywhere until it gets that final answer.

The last argument to handle_call is the current state of the GenServer process. I mentioned the handle_cast function in the quoted text above. We will see it in action in our Item GenServer process. Consider the snippet below:

defmodule ItemServer do
  use GenServer

  # -------------------------
  # Client API
  # -------------------------

  def start_link({name, qty, unit_price}) do
    GenServer.start_link(
      __MODULE__,
      %{name: name, qty: qty, unit_price: unit_price}
    )
  end

  def reserve(pid, requested_qty) do
    GenServer.call(pid, {:reserve, requested_qty})
  end

  def return_item(pid, qty) do
    GenServer.cast(pid, {:return_item, qty})
  end

  def get_state(pid) do
    GenServer.call(pid, :get_state)
  end

  # -------------------------
  # Server Callbacks
  # -------------------------

  @impl true
  def init(state) do
    {:ok, state}
  end

  @impl true
  def handle_call({:reserve, requested_qty}, _from, state) do
    cond do
      state.qty >= requested_qty ->
        new_state = %{
          state
          | qty: state.qty - requested_qty
        }

        {:reply,
         {:ok,
          %{
            item: state.name,
            qty: requested_qty,
            unit_price: state.unit_price
          }}, new_state}

      true ->
        {:reply,
         {:error,
          :insufficient_stock,
          state.name,
          state.qty}, state}
    end
  end

  @impl true
  def handle_call(:get_state, _from, state) do
    {:reply, state, state}
  end

  @impl true
  def handle_cast({:return_item, qty}, state) do
    new_state = %{
      state
      | qty: state.qty + qty
    }

    {:noreply, new_state}
  end
end

In the snippet above, we introduced another GenServer callback called handle_cast. The client API that triggers handle_cast is the GenServer.cast/2 function. Note the return value of the handle_cast function — it’s a tuple with two items. The first item of the tuple is an atom with value (:noreply), while the second item is the updated state. That first item indicates that no response is sent back to the caller. In our cart implementation, we used it when a customer returns an item to the shop — that is a practical example of an operation in which you are not interested in its result. Apart from these, the logic from when we used spawn, send & receive is still the same.

One other GenServer callback that’s worth mentioning is the handle_info/2. It is used for handling messages from external sources in a GenServer process. Messages from a client are handled by the handle_cast/2 and handle_call/3 callbacks inside the GenServer module. However, there are scenarios where external processes need to alert the GenServer process of a situation by using the send/2 function. When such messages are sent, the handle_info/2callback is responsible for handling them.

When Should You Actually Consider a GenServer?

Elixir GenServers are cool — but the thing about cool toys is that when you don’t properly understand them, you might end up hurting yourself or even damaging the toy.

As a rule of thumb, you should consider using a GenServer when you need to manage long-lived state that must be shared, coordinated, or updated sequentially over time.

That rule of thumb gives you a starting point. But let me be more specific. A problem domain should meet most of the following requirements before you reach for GenServer:

  • Long‑lived state — When you need a state that must be tracked across multiple interactions or over time — not just a one‑time calculation. Example: a shopping cart that persists as the user adds and removes items, as we saw earlier. A naive approach would hit the database on every add_item or remove_item call. That adds latency and unnecessary write load. A more scalable pattern is to keep the active cart in a GenServer for fast, in‑memory updates, while persisting to the database in the background. The database remains the source of truth (surviving system failures), but the GenServer offers you the benefit of low‑latency operations. Just be aware: if the GenServer crashes before the background write completes, that update is lost. This is where you need to make an informed decision as an engineer to choose between faster updates and data integrity.
  • Shared across processes — When you need multiple processes that have to read and write to the same state in a sequential order.
  • Need for synchronous replies — When you need the operation to return a result to the caller, and the caller has to wait for that result before proceeding. Example: “Did the payment go through?” (handle_call). If you don't need a reply, handle_cast is fine, but then you end up trading away synchronous behaviour.
  • Fault tolerance — When you want the process to be supervised and automatically restarted if it crashes. This is the case when your GenServer is started by a supervisor (not manually with start_link outside a supervision tree).

If your problem satisfies most of these requirements, GenServer is probably a good idea; if it meets just one or two, it's better to consider other alternatives like Agent, Task, or even functions.

Conclusion

Elixir GenServers are a powerful way to manage changing state in concurrent applications. But as we’ve seen throughout this article, they are not always the right tool. Sometimes a simple Agent, or a plain module can be a better fit. The key is knowing when to reach for GenServer — and when to leave it in the toolbox.

We also mentioned allowing Supervisors to start your GenServer process if you want it to be fault-tolerant — later in the future, we will dive deeper into Supervision and fault tolerance.

Further reading: If you’re intending to incorporate GenServer into your own projects, I strongly recommend the official GenServer documentation and Sasa Juric’s book: Elixir in Action — chapter VI. They explore the trade-offs (and sharp edges) that this article only introduces.


메타데이터
post_id
30fb116eb9fb
slug
beyond-raw-processes-building-the-shopping-cart-with-elixir-genserver-30fb116eb9fb
url
https://medium.com/@nature.exs/beyond-raw-processes-building-the-shopping-cart-with-elixir-genserver-30fb116eb9fb
canonical_url
https://medium.com/@nature.exs/beyond-raw-processes-building-the-shopping-cart-with-elixir-genserver-30fb116eb9fb
author_url
https://medium.com/@nature.exs
status
ok
fetched_at
2026-06-09 15:37:30