anchor

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?

“Elixir's biggest problem now is adoption. Whatever helps with adoption should become a priority.”
Peter Solnica on X (Twitter)

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.

Business Logic
Infrastructure Logic
Product catalog or inventory management
Authentication
Order placement and tracking
Caching, messaging, queuing
Inventory Pricing and discount calculations
Logging and monitoring, error handling
Customer account management
Interactions with external systems
Reporting and analytics
Deployment and scaling

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:

1
Maintaining it this way is easier
By keeping the business logic separate, you create a more transparent system for maintaining, testing, and modifying that specific application part without affecting other components.
2
We can reuse it later
The business logic can be reused across different parts of the application or even different applications altogether, promoting code reuse and consistency. Which, in turn, means less fuss scaling the system and adding new features.

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?

Freshcode Tip
Functional paradigm languages operate pure functions and pattern matching. Instead of having separate classes or modules for business logic, the logic is encapsulated within functional constructs. The functions are easy to test, maintain, and reason about, as they have no side effects and always return the same output for the same input. On top of that, immutable data structures make it easy to compose and transform data using functions. Neat!

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:

1
Domain Modules
These modules represent the core domain concepts and encapsulate the business rules and logic specific to that domain.
2
Schema Modules
These modules define the data structures (schemas) used by the domain modules. They often represent database models and define validations, relationships, and other data-related concerns.
3
Context Modules
This module acts as the entry point for the context, exposing a public API to interact with the domain modules and providing a layer of abstraction over the internal implementation details.

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:

On the diagram: user's Request triggers Action within the system that forms Response sent back to the user.

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:

On the diagram: the user sends HTTP Request to WebServer, where OTP lives. WebServer cheks user's Context for Current Permissions to access Data and form a Response.

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:

Promote separation of concerns and a modular architecture
Isolate core domain rules and operations
Provide a clear interface for interacting with the business logic
Facilitate easier testing and maintenance
Enable reuse of domain logic across different parts of your application

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.

Author
linkedin
Volodymyr Sverediuk
Elixir Developer

A software developer with extensive years of experience. For the past decade, my focus has been on web development utilizing Elixir, Ruby, and JavaScript.

linkedin
Sofiia Yurkevska
Content Writer

Infodumper, storyteller and linguist in love with programming - what a mixture for your guide to the technology landscape!

Shall we discuss
your idea?
Uploading...
fileuploaded.jpg
Upload failed. Max size for files is 10 MB.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
What happens after you fill this form?
We review your inquiry and respond within 24 hours
We hold a discovery call to discuss your needs
We map the delivery flow and manage the paperwork
You receive a tailored budget and timeline estimation