OTP, GenServer, Processes, and WebSockets are the day's topics inside the Elixir community. These are truly fascinating to reason about with the air of innovation they bring. But what if we shift the discourse from more substantial issues?
And we can not agree more with Peter. To top this statement up, we believe that the applied usage of Elixir paves the path for its adoption. Setting aside Elixir's technical advantages, we still need to build usable and maintainable systems that deliver value to the client because, at the end of the day, The Spice Must Flow. How do we do that? And what are the obstacles?
What is Business Logic?
Business logic refers to the rules, operations, and calculations that define how a business process or application should work. It's the "mind" that decides how data is transformed, validated, computed, and manipulated to produce the desired outcome. What’s more important, business logic is the layer the end-user directly interacts with or experiences. It’s the business logic’s work to validate customer info, calculate taxes and costs, check what’s if user can see or change an item (if we’re talking ERPs and e-commerce), and form all the pretty reports. Which boils down to who can or can not perform certain actions (also known as CRUD operations) in the system.
If there’s business logic, there should be some other kinds of logic too, right? Let’s use the ERP example in more detail and see how business logic differs.
It’s easy to spot from the table that business logic is more high-level. It means that we need to build it on top of some infrastructure. Getting back to the business logic-is-a-mind metaphor, infrastructure, in this case, is the body. You can build an incredibly strong, fault-tolerant and robust “body” with Elixir, but what’s the use of that hanging around mindlessly?
Here’s another philosophical question for you: why do we draw a line between “body” and “mind”? In software development, we separate the business logic from other parts of the application for two reasons:
Consider an ERP system with access controls scattered throughout the codebase. The code for determining user privileges and contextual access rights is tightly coupled with various modules, making it difficult to modify or adapt as the organization's structure evolves. Good luck implementing new department-specific permission or adjusting access levels across multiple modules in this tangled structure. Abstractions and separations of concerns make it easier to extend or replace parts of the business logic without affecting the entire application. Still sounds like too much trouble?
Meet the Context!
Now we know why we keep business logic separated. If only there were a tool to do so! In Elixir, contexts organize and encapsulate related functionality within an application's domain. They act as an interface between the external world (such as web requests or other parts of the application) and the internal domain logic, providing a layer of abstraction and separation of concerns.
Contexts are typically organized around specific bounded contexts or domains within the application's business logic. For example, in an e-commerce application, you might have contexts for handling products, orders, users, and payments.
Each context typically consists of the following components:
The main purpose of contexts is to separate the application's business logic from the delivery mechanisms (such as web interfaces, APIs, or other external integrations). By encapsulating the domain logic within contexts, you can easily swap out different delivery mechanisms without affecting the core business logic.
Implement the Context!
So far, so good with the theory. Getting to the practical part, where do we put our so special and separated Business Logic? Let’s review a Hello-World example of a basic ERP system for inventory items management using <span style="font-family: courier new">mix phx.gen.auth</span> for authentication. We have a user schema with the role column, with enum values such as Supervisor, Manager, and Worker. The full project’s codebase is here. Typically our app works like this:
![](https://cdn.prod.website-files.com/64451fa6415fbb6349d3d3f4/667c1726da019b5d7b2f4d66_01.webp)
That’s the ol’ good Model-View-Controller design pattern we all use and love. Of course, this oversimplification doesn’t answer where to put business logic, so let’s detail it a bit:
![](https://cdn.prod.website-files.com/64451fa6415fbb6349d3d3f4/667c17bb8419c0f5ef833fc3_02.webp)
Here we have it! After the User’s HTTP request reaches the Web Server, we enter the Context layer to check whether we can perform the requested operation and fetch the information for the user. Now let's proceed with crafting the Context itself. First, we define context as a simple data structure that is responsible for managing current user and user permissions. It's a transport struct that converts HTTP requests into business requests:
1defmodule Erp.Context do
2 defstruct user: nil, permissions: %{}
3end
For <span style="font-family: courier new">%Erp.Context{}</span> initialization: we need to mount a special hook into web app helpers. In our case it’s <span style="font-family: courier new">lib/erp_web.ex</span>. Just add one line to the <span style="font-family: courier new">live_view</span> function:
1# .. head of the file
2
3def live_view do
4 quote do
5 use Phoenix.LiveView,
6 layout: {ErpWeb.Layouts, :app}
7
8 # Add this on_moutn hook here
9 on_mount(ErpWeb.Live.Hooks.AssignContext)
10
11 unquote(html_helpers())
12 end
13 end
14
15# rest of the file ...
AssignContext hook is a simple transfer <span style="font-family: courier new">current_user</span> from the session, permissions build, and assign it as <span style="font-family: courier new">%Erp.Context{}</span> struct under <span style="font-family: courier new">ctx</span> key:
1defmodule ErpWeb.Live.Hooks.AssignContext do
2 import Phoenix.Component, only: [assign: 2]
3
4 def on_mount(:default, _params, %{"current_user" => user}, socket) do
5 permissions = Erp.Permissions.build(user)
6 ctx = %Erp.Context{user: user, permissions: permissions}
7
8 {:cont, assign(socket, ctx: ctx)}
9 end
10
11 def on_mount(:default, _params, _sesssion, socket) do
12 {:cont, socket}
13 end
14end
For explicit authorization, we need Permissions management. After defining a default Policy, we broaden each role's permissions. As you can see, Supervisor role has extended permissions to fully operate data, including deleting items. The Worker role, in turn, can only read records, when the Manager can also create and update records but not delete.
1defmodule Erp.Permissions do
2 defstruct read_items: false, create_items: false, update_items: false, delete_items: false
3
4 alias Erp.Context
5
6 def build(%Erp.Accounts.User{role: :supervisor}) do
7 %__MODULE__{
8 read_items: true,
9 create_items: true,
10 update_items: true,
11 delete_items: true
12 }
13 |> Map.from_struct()
14 end
15
16 def build(%Erp.Accounts.User{role: :manager}) do
17 %__MODULE__{
18 read_items: true,
19 create_items: true,
20 update_items: true
21 }
22 |> Map.from_struct()
23 end
24
25 def build(%Erp.Accounts.User{role: :worker}) do
26 %__MODULE__{
27 read_items: true
28 }
29 |> Map.from_struct()
30 end
31
32 def can?(%Context{permissions: permissions}, action) do
33 permissions[action] == true
34 end
35
36 def authorize(%Context{} = ctx, action) do
37 ctx
38 |> can?(action)
39 |> case do
40 true -> {:ok, :authorized}
41 false -> {:error, :not_authorized}
42 end
43 end
44end
Now, on the web layer, like the LiveView module, we can use <span style="font-family: courier new">ctx</span> as the single point of truth of what the user can or cannot do and even pass it further. This bridges web requests such as <span style="font-family: courier new">@conn</span> or <span style="font-family: courier new">@sockert</span> and the business context.
1# ... head of the file
2
3defp apply_action(%{assigns: %{ctx: ctx}} = socket, :edit, %{"id" => id}) do
4 ctx
5 |> Inventory.fetch_item(id)
6 |> case do
7 {:ok, item} ->
8 socket
9 |> assign(:page_title, "Edit Item")
10 |> assign(:item, item)
11
12 {:error, :not_found} ->
13 socket
14 |> put_flash(:error, "Not found")
15
16 {:error, :not_authorized} ->
17 socket
18 |> put_flash(:error, "Forbidden")
19 end
20 end
21
22# rest of the file...
<span style="font-family: courier new">Erp.Inventory</span> context can now accept <span style="font-family: courier new">ctx</span> as the first argument of each function and provide action authorization. This design guarantees that business requests will be handled idempotently. No matter where a request was received from Web, API, or WebSocket, we can always guarantee the same response if we proceed with <span style="font-family: courier new">ctx</span> in a business context.
Here we elaborate on which permissions actions within the system need. This way every action within the system knows who can or cannot perform it, and - as a nice bonus - can log it.
1defmodule Erp.Inventory do
2 import Ecto.Query, warn: false
3 alias Erp.Repo
4 alias Erp.Context
5 alias Erp.Inventory.Item
6
7 def list_items(%Context{} = ctx) do
8 with {:ok, :authorized} <- Erp.Permissions.authorize(ctx, :read_items) do
9 Repo.all(Item)
10 end
11 end
12
13 def fetch_item(%Context{} = ctx, id) do
14 with {:ok, :authorized} <- Erp.Permissions.authorize(ctx, :read_items) do
15 Repo.fetch(Item, id)
16 end
17 end
18
19 def create_item(%Context{} = ctx, attrs \\ %{}) do
20 with {:ok, :authorized} <- Erp.Permissions.authorize(ctx, :create_items) do
21 %Item{}
22 |> Item.changeset(attrs)
23 |> Repo.insert()
24 end
25 end
26
27 def update_item(%Context{} = ctx, %Item{} = item, attrs) do
28 with {:ok, :authorized} <- Erp.Permissions.authorize(ctx, :update_items) do
29 item
30 |> Item.changeset(attrs)
31 |> Repo.update()
32 end
33 end
34
35 def delete_item(%Context{} = ctx, %Item{} = item) do
36 with {:ok, :authorized} <- Erp.Permissions.authorize(ctx, :delete_items) do
37 Repo.delete(item)
38 end
39 end
40
41 def change_item(%Item{} = item, attrs \\ %{}) do
42 Item.changeset(item, attrs)
43 end
44end
As your business rulebook and lists of requirements grow, you’ll have to expand the codebase and keep it manageable. Contexts are the tool that leaves you a handy opening for extention. More than that, you get a transparent and maintainable system with this approach.
Finally, Embrace the Context!
Separating business logic from the rest of your application is crucial for maintainability, testability, and reusability. In Elixir, the concept of Contexts provides an organized way to encapsulate related functionality and domain logic. Context sets the playbook for your application to operate seamlessly.
By structuring your application's business logic into distinct Contexts, you can:
Don't let your business logic become tangled with delivery mechanisms or infrastructure concerns. Embrace the power of Contexts and keep your application's "mind" focused on the essential rules that drive your business. Need a hand with it? Don’t hesitate to contact us.