Context maintainability & guidelines in Elixir & Phoenix

Context maintainability & guidelines in Elixir & Phoenix

The concept of Phoenix Context may appear straightforward, yet its potential for significantly boosting Phoenix app maintainability is profound. However, the truth is many developers grapple with its effective implementation.

Table of contents

    In this blog post, I will unveil a strategic solution for optimizing context organization, a critical step towards sustainable and efficient Phoenix app development.

    Phoenix Context

    The origin story

    I still remember times when Phoenix Context wasn't a thing in our beloved framework. Before it was introduced as a building block of modern web applications in Phoenix, whenever you wished to interact with a database, you would define a model. Essentially, a model contained an Ecto schema and a set of functions to perform CRUD (and other) operations on the resource. Need a time machine? Take a look at the "old times" docs: https://hexdocs.pm/phoenix/1.3.0-rc.2/ecto_models.html.

    From my perspective, this concept had one significant disadvantage. It forced you to think about the Phoenix web application as a set of unrelated data resources. Long term, this is not ok because we all know that resources are, in most cases, very much connected. Phoenix Context came into the stage to tackle this issue.

    What is a Phoenix context?

    Instead of thinking about the Phoenix app as a set of unrelated data resources, Phoenix Context suggests grouping them in "clusters". The reasoning behind forming a given group should not be random. The best-case scenario is when the cluster consists of resources that together form a specific business domain within the application.

    Let's say your application is called App , and it's a premium blog, which means you have to pay to get in. You can imagine that this app defines the following contexts:

    App.Account

    Here, all account-related operations will be performed. Things such as:

    • Authentication
    • Authorization
    • Users CRUD operations
    • Admins CRUD operations, if you decide to make it separate from Users

    App.Blog

    Here, all blog content-related operations will be performed, such as:

    • Posts CRUD operations
    • Comments CRUD operations
    • Media CRUD operations, like images or videos

    I can imagine much more things going into this context, but let's keep it simple.

    App.Billing

    Here, all billing-related operations will be handled. You can imagine subscriptions, payments and customer management in one place.

    Phoenix Context Boundaries Example for Account, Blog and Billing

    In the old Model approach, developers would define these resources anyway, but there would be a lack of a bigger picture.

    Phoenix Context vs Bounded Context in Domain-Driven Design

    In Domain-Driven Design (also known as DDD), the term "Bounded Context" refers to a specific responsibility within the software, with clear boundaries that distinguish it from other responsibilities. These boundaries help to avoid the entanglement of different domain models, ensuring that each model remains focused on its specific problem space.

    While not explicitly derived from DDD, Phoenix Contexts embody the same principle. They provide a dedicated module that groups related functionality, effectively creating a "boundary" around a specific feature set or area of concern within the application.

    I have to point out that to form such boundaries effectively, you'll need to follow specific rules that I will share in this article.

    Why Phoenix Context approach is better than Ecto Model

    I consider the Phoenix Context approach a great step towards web application maintainability, and here are some concrete reasons why.

    Better Organization and Encapsulation

    Contexts provide a clearer way to encapsulate functionality and manage dependencies. Each context deals with a specific part of your application's business logic. It's easier this way to see how different parts of your application interact with each other.

    Improved Maintainability

    Maintaining and updating your code is easier when related resources' functionalities are grouped together. If you need to change something about how a feature works, you'll typically only need to change code within a single context.

    Clearer Boundaries

    Contexts define clear boundaries between different parts of your application, which allows you to see the "big picture" of how your application works. This is especially helpful in larger codebases, where it can be hard to understand how different resources relate to each other.

    Reduced Coupling

    Coupling leads to issues with maintainability, scalability, and testing. Defining clear boundaries between different parts of your application with contexts will help you reduce or eliminate the coupling between domains.

    Enhanced Testability

    Since, thanks to Phoenix Context, our app business domains are now isolated, it's easier to write predictable tests for each of them.

    Road to maintainable Phoenix Contexts

    Phoenix Context is quite a simple concept. Nonetheless, it's quite often used in the wrong way. The main goal of this article is to introduce easy-to-follow steps that, in the long term, will help you maintain the code. Let's go through these steps one by one in this section.

    1. Spend some time on the definition of the context

    This step is not even code-related. It's all about a higher level of thinking before any line of code is written. If you're thinking about introducing a new resource in the app, first ask yourself:

    Is there already a context within the app, that this resource belongs to?

    If the answer is yes, then you already know where to define the resource. However, if the answer is no, then you should spend some time answering the next question:

    What kind of app module am I building with this new resource?

    Be careful with both questions since:

    1. You don't want to build too big contexts. Usually, it's possible to create smaller ones.
    2. You don't want to end up with "one resource contexts". This path would eventually lead to a solution similar to old Ecto Models, which we're trying to get away with here.

    While it may appear straightforward, defining new contexts and maintaining existing ones is no simple task. This process requires developers to adopt an abstract mindset, thinking independently of the code. This requires a shift away from concrete coding tasks towards more conceptual considerations.

    2. Dividing big context modules into smaller ones

    Sooner or later, you'll discover that context modules grow fast. The documentation states that context-related logic should be defined within the context module. Whenever you use context generator, such as:

    mix phx.gen.context Account User users email:string

    The resource-related logic, in this case User, will be added (or created) within the context called Account. Calling this generator again, but this time for admin:

    mix phx.gen.context Account Admin admins email:string

    will add admin-related functions to already existing Account context file.

    Imagine having around four resources grouped in such a context. All in all, it leads to big, if not huge context files.

    To illustrate a very simple yet already troublesome scenario, take a look at the Account and Blog contexts I generated in a sample project: https://github.com/curiosum-dev/elixir-phoenix-context-blog-post/tree/feature/contexts.

    Only four resources are within Account context, and it's already 392 lines long. Our goal here is to reduce it drastically.

    Dividing context

    To split the context into smaller ones, let's create a solution where:

    • lib/account.ex context file will contain all of the functions available within Account context
    • lib/account/subcontexts/[plural resource name].ex will contain operations for each resource defined within Account context

    Note that I introduce subcontext terminology here. This name doesn't exist in Phoenix, but we use it in Curiosum to mark that it's a subset of context.

    So, let's say that our Account module looks like this:

    lib/account.ex

    defmodule App.Account do
      alias App.Repo
      alias App.Account.{User, Admin}
    
      # User related functions
      def list_users do
        Repo.all(User)
      end
    
      def get_user!(id), do: Repo.get!(User, id)
    
      # Other User related functions
      ...
    
      # Admin related functions
      def list_admins do
        Repo.all(Admin)
      end
    
      def get_admin!(id), do: Repo.get!(Admin, id)
    
      # Other Admin related functions
      ...
    end

    Let's start with splitting user-related functions into another module:

    lib/account/subcontexts/users.ex

    defmodule App.Account.Users do
      alias App.Repo
      alias App.Account.User
    
      # User related functions
      def list_users do
        Repo.all(User)
      end
    
      def get_user!(id), do: Repo.get!(User, id)
    
      # Other User related functions
      ...
    end

    The same goes for admin-related functions:

    lib/account/subcontexts/admins.ex

    defmodule App.Account.Admins do
      alias App.Repo
      alias App.Account.Admin
    
      # Admin related functions
      def list_admins do
        Repo.all(Admin)
      end
    
      def get_admin!(id), do: Repo.get!(Admin, id)
    
      # Other Admin related functions
      ...
    end

    Finally, to make all of them available in account context, let's use defdelegate :

    defmodule App.Account do
      alias App.Account.Admins
      alias App.Account.Users
    
      # User related functions
      defdelegate list_users(), to: Users
      defdelegate list_users(id), to: Users
      # Other User related delegates
      ...
    
      # Admin related functions
      defdelegate list_admins(), to: Admins
      defdelegate list_admins(id), to: Admins
      # Other Admin related delegates
      ...
    end

    With this context design, at Curiosum, we aim to do the following things:

    1. When we test context, we do it in a test file for a given subcontext but always call the function from the context itself. So, for instance, for user-related functions, tests would be defined within test/app/account/subcontexts/users_test.ex , but for each test, we would call a function directly from App.Account context. Thanks to that, we don't end up with huge context test files.
    2. We always interact with context, never with subcontext. So instead of calling App.Users.list_users , you would call App.Account.list_users.
    3. When given function from Account context needs to interact with two or more resources defined within this context, we implement it within lib/account/subcontexts/shared.ex file. Thanks to that, it's very straightforward regarding when to put a given function,

    And that's it. The hard part, though, is to consistently stick to these rules so that we don't end up with a mess after all.

    You can see how I divided Account and Blog contexts into subcontexts and applied delegations in our sample project on Github: https://github.com/curiosum-dev/elixir-phoenix-context-blog-post/tree/feature/context-and-subcontexts.

    3. Never do cross-context referencing

    How many times have you found yourself in the following scenario:

    I need to implement an operation, that interacts with x resources defined within y contexts. Where should I implement it?

    The most common scenario is either:

    1. Controller, which would give it too many responsibilities.
    2. Context file, which always creates cross-referencing, or in short - coupling.

    Neither of these scenarios is desired, but there is an easy solution to this problem.

    Meet Services (or call it as you want)

    Internally, at Curiosum, whenever there are two or more contexts involved in a given operation, we create a special Service module.

    Let's return to the example of a premium blog I pictured at the beginning of this article. To view a premium article, you should pay first. Once the payment is made, a special User column called is_subscribed might be set to true. This has to be an atomic operation, therefore let's use transactions (Not familiar with transactions? Check our blog post: https://curiosum.com/blog/elixir-ecto-database-transactions):

    lib/services/subscribe_user_to_premium_service.ex

    defmodule App.SubscribeUserToPremiumService do
      alias Ecto.Multi
    
      def run(user, billing_details) do
        Multi.new()
        |> Multi.run(:pay_for_subscription, pay_for_subscription(billing_details))
        |> Multi.run(:update_user_is_subscribed_column, update_user_is_subscribed_column(user))
        |> App.Repo.transaction()
      end
    
      ...
    end

    For simplicity, I'm not implementing the private functions mentioned in Multi.run/3.

    This solution has a disadvantage - for each operation like this one, you have to create a new file. It's also quite hard to put these files into folders since they relate to resources from different contexts, so the big question then is - how to name the folders? In the above case, it can be both /billing as well as /account and putting it into either of these will depend on developers' preferences.

    The goal, though, here is to maintain clarity and simplicity, so I would advise to create Service module for reach operations like this one and put it directly to /services folder.

    What are the benefits?

    1. No coupling.
    2. Easy testing.
    3. Easy-to-follow rule.

    One last thing is naming. I picked a Service name, but you can call it as you want. Just make sure that your team understand the goal behind it.

    4. Optionally, spot repetitive operations, and use macros

    Let's be honest with each other - in most web apps, each context has a subset of repetitive functions. These are usually CRUD functions.

    Depending on your team's preferences, you may want to define a module that injects such operations with use function. It can significantly reduce the time needed to develop context files, but as developers are divided on this topic, I decided to tag it as an "optional" one.

    5. Use tooling to focus on productive work

    I will write another article on this topic (stay tuned and subscribe to our newsletter), but you can already test a library we wrote at Curiosum to help you easily follow points 2, 3 and 4 from this article. Its goal is to:

    • Spot cross-references
    • Help you delegate
    • Generate CRUD operations

    Take a look: https://github.com/curiosum-dev/contexted.

    Summary

    Elixir developers often tend to choose umbrella apps approach to force the decoupling of business logic context files. Umbrella has its flaws, and you don't need it for this particular goal. You can maintain contexts right, even when using a good old monolith approach. The proposed solution is a set of easy-to-follow rules that, if applied properly will help you maintain contexts right.

    One last thing I'd like to mention here is that in this approach, it's quite easy to divide big applications into smaller ones. You already have nicely designed folders that encapsulate a given domain. Each domain can eventually become a core of a separate application, which is a huge win.

    If you're looking for consulting on this topic, feel free to contact us at our contact page or read more about our Elixir & Phoenix development services.

    I hope you enjoyed this article!

    FAQ

    What is Phoenix Context in Elixir and Phoenix?

    Phoenix Context groups related functionality within an Elixir and Phoenix application, forming a specific business domain, which helps in organizing and maintaining the code effectively.

    How does Phoenix Context improve maintainability in web applications?

    Phoenix Context improves maintainability by providing better organization, clearer boundaries, reduced coupling, and enhanced testability for different parts of an application.

    How does Phoenix Context compare to Bounded Context in Domain-Driven Design?

    While not explicitly derived from Domain-Driven Design, Phoenix Contexts share similar principles with Bounded Context by creating clear boundaries around specific features or areas within an application.

    What are the key steps to maintaining Phoenix Contexts?

    Key steps include defining the context carefully, splitting large context modules into smaller ones, avoiding cross-context referencing, and potentially using macros and tooling to streamline context maintenance.

    What should be considered when dividing big context modules?

    Consider creating subcontexts for different resources within a context to prevent files from becoming too large and to maintain a clear structure and focus for each module.

    What is the role of Services in Phoenix Context maintainability?

    Services are used to handle operations involving multiple contexts without creating coupling, thus maintaining clear separation and reducing complexity.

    How can Elixir and Phoenix developers ensure effective use of Phoenix Context?

    Developers should ensure clear separation of concerns, avoid unnecessary coupling between contexts, and follow consistent guidelines for context and subcontext creation and management.

    What is the impact of Phoenix Context on testing in Elixir and Phoenix applications?

    Phoenix Context enhances testability by isolating business domains, making it easier to write and manage tests for different parts of an application.

    Szymon Soppa Web Developer
    Szymon Soppa Curiosum Founder & CEO

    Read more
    on #curiosum blog