anchor
Insights /  
What About the Business Logic in Elixir?

What About the Business Logic in Elixir?

June 27, 2024
7 min read
Elixir
By
Volodymyr Sverediuk
Elixir Developer
Sofiia Yurkevska
Content Writer
In this article
See more
This is some text inside of a div block.

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 ther 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 HTTP Request to WebServer, where OTP lives. WebServer checks user's Context to 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.

Build Your Team
with Freshcode
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!

Share your idea

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
A 30-minute discovery call is scheduled with you
We address your requirements and manage the paperwork
You receive a tailored budget and timeline estimation

Talk to our expert

Kareryna Hruzkova

Kate Hruzkova

Elixir Partnerships

Our team scaling strategy means Elixir developers perform from day one, so you keep your product on track, on time.

We review your inquiry and respond within 24 hours

A 30-minute discovery call is scheduled with you

We address your requirements and manage the paperwork

You receive a tailored budget and timeline estimation

elixir logo

Talk to our expert

Nick Fursenko

Nick Fursenko

Account Executive

With our proven expertise in web technology and project management, we deliver the solution you need.

We review your inquiry and respond within 24 hours

A 30-minute discovery call is scheduled with you

We address your requirements and manage the paperwork

You receive a tailored budget and timeline estimation

Looking for a Trusted Outsourcing Partner?