Introducing Contexted library Phoenix Contexts, Simplified

Introducing Contexted – Phoenix Contexts, Simplified

One of the most unproductive activities an Elixir software developer can engage in is determining which module should implement a given function and how to maintain complex business logic.

Table of contents

    Contexted is designed to bring some of this time back with helpful checks and macros.

    Why do you need Contexted?

    In my previous article, Context Maintainability & guidelines in Elixir & Phoenix, I proposed an approach to maintainable Phoenix contexts that we use in our projects at Curiosum. I strongly suggest reading that blog post first since Contexted Library is the answer to the challenges and issues related to context maintainability that I discussed in that article.

    However, in short, Contexted is for you if:

    1. Your contexts are becoming a mess, with too many functions defined in each module.
    2. Your contexts are not defined properly; there are too many or too few.
    3. You need a way to manage your contexts in a maintainable way for a new project.
    4. You need a way to gradually refactor an existing project to eventually make it maintainable.
    5. Your compilation times start increasing due to overly large or poorly organized contexts.

    These issues usually happen when the team has not spent enough time on conceptual work, which essentially means splitting resources into the rights domains (before writing the code), and due to a lack of mechanisms that would prevent developers from defining functions in the wrong context modules.

    Setting up Contexted in Elixir project

    In your existing Elixir project, go to mix.exs file and add Contexted library:

    defp deps do
      [
          ...
        {:contexted, "~> 0.3.3"}
      ]
    end

    Since further configuration steps depend on whether you’d like to use all of the Contexted functionalities or just selected ones, let’s see what it can do.

    The Contexted library is available on Github: https://github.com/curiosum-dev/contexted.

    Use cases of this library are presented in the contexted_examples repository: https://github.com/curiosum-dev/contexted_examples

    Protecting the Phoenix contexts boundaries

    One of the most common issues I observe in hard-to-maintain Elixir & Phoenix projects are cross-references in context modules. For instance:

    Accounting context calling Accounts context when processing a payment

    In general, we can call it coupling, and it’s never a good idea not only in OO programming but also in functional. Here are some reasons why:

    1. Changes in one context module may affect functionality in other context modules.
    2. It’s hard to test non-isolated context modules and their functions.
    3. Lack of clarity regarding the responsibilities of each context module.

    Contexted allows you to raise error during compilation, whenever there is a coupling between your application contexts. Since Phoenix context is just a concept, with no difference to a simple Elixir module, we need to first inform the Contexted library about the list of contexts within your app, so that we can spot the cross-references later on.

    Go to your config.exs file, and add the following configuration:

    config :contexted,
        contexts: [
            # list of context modules goes here
        ]

    To be more precise, let’s imagine, that your application has three contexts: App.Accounts, App.Subscription, App.Blog . The config file in this case should look like this:

    config :contexted,
      contexts: [
        App.Accounts, App.Subscription, App.Blog
      ]

    There are two strategies you may have:

    1. List all context modules at once - this can be a good idea, especially for new projects.
    2. List a small subset of context modules first, and start refactoring them one by one, adding new ones over time.

    Next, you need to add :contexted to compilers in mix.exs :

    def project do
      [
        ...
        compilers: [:contexted] ++ Mix.compilers(),
        ...
      ]
    end

    Spotting the coupling depends on compilation tracers, hence this step is required. In short, during the compilation, the Contexted library will spot any reference from one context to another and raise the error when that happens.

    From now on, whenever you compile the code, and there is a cross-reference, you should spot an error similar to this one:

    == Compilation error in file lib/app/accounts.ex ==
    ** (RuntimeError) You can't reference App.Blog context within App.Accounts context.

    (where of course App.Blog and App.Accounts will be replaced with your actual context modules)

    Check how this particular feature works in example repo: Here.

    Dividing big context modules into smaller parts

    Setting up delegations

    Context modules grow fast over time. Even small apps can quickly grow context modules to hundreds of lines of code. Result? Challenges with readability, maintainability, and even code architecture.

    Contexted is equipped with delegate_all macro to help you divide context modules into smaller ones - let’s call them subcontexts.

    Let’s assume that App.Accounts context module implements the following functions:

    defmodule App.Accounts do
      def get_user(id) do
        ...
      end
    
      def get_user_token(id) do
        ...
      end
          
      def get_admin(id) do
        ...
      end
    end

    We can split this module into smaller ones like this:

    # Users subcontext
    
    defmodule App.Accounts.Users do
      def get_user(id) do
        ...
      end
    end
    
    # UserTokens subcontext
    
    defmodule App.Accounts.UserTokens do
      def get_user_token(id) do
        ...
      end
    end
    
    # Admins subcontext
    
    defmodule App.Accounts.Admins do
      def get_admin(id) do
        ...
      end
    end

    And create delegations in App.Accounts context module:

    defmodule App.Accounts do
      import Contexted.Delegator
    
      delegate_all App.Accounts.Users
      delegate_all App.Accounts.UserTokens
      delegate_all App.Accounts.Admins
    end

    As a result, every function defined in subcontexts will be available in the App.Accounts context module, which should be treated as the main gateway to account-related business logic within the application. Subcontexts are meant solely to help structure the code—each one may, for example, group functions related to a specific resource, as shown in the example above. In the end, however, developers have the flexibility to structure it as they prefer.

    Check how this particular feature works in example repo: Here.

    Accessing docs and specs

    Both docs and specs are saved as metadata of the module once it's compiled and saved as .beam. Since delegate_all macro works in compile-time, we can’t access those in subcontexts files during compilation. There is a way though to still make it work.

    If you’d like to access both docs and specs within the compiled contexts modules for delegated functions, set up enable_recompilation and app options in contexted config:

    config :contexted,
      ...,
      app: :your_app_name, # replace 'your_app_name' with your real app name
      enable_recompilation: true

    With the enable_recompilation flag set to true, once the context is compiled, it will remove the context beam file and compile it again. During the second compilation, we’re 100% sure that subcontexts metadata is already defined, and as a result, when delegations are created, we can copy the docs and specs from subcontexts functions to context functions.

    Autogenerating CRUD functions

    One of the most common functions we create in context modules are CRUD operations. To "reduce the Elixir module’s weight” there is a bonus feature in Contexted - the ability to autogenerate CRUD functions.

    Considering that there is App.Accounts.Users subcontext in the application, you can autogenerate CRUD functions like this:

    defmodule App.Accounts.Users do
      use Contexted.CRUD,
        repo: App.Repo, # pass your app repo here
        schema: App.Accounts.User # pass your app resource here
    end

    Result:

    iex> App.Accounts.Users.__info__(:functions)
    [
      change_user: 1,
      change_user: 2,
      create_user: 0,
      create_user: 1,
      create_user!: 0,
      create_user!: 1,
      delete_user: 1,
      delete_user!: 1,
      get_user: 1,
      get_user!: 1,
      list_users: 0,
      update_user: 1,
      update_user: 2,
      update_user!: 1,
      update_user!: 2
    ]

    There are also two additional options you may use here:

    defmodule MyApp.Accounts do
      use Contexted.CRUD,
        repo: MyApp.Repo,
        schema: MyApp.Accounts.User,
        exclude: [:delete], # a list of actions you'd like to skip in generation
        plural_resource_name: "users" # resource plural name in case it doesn't end with 's'
    end

    Check how this particular feature works in example repo: Here.

    Summary

    If you battle with ever-growing contexts, have trouble maintaining them, and end up with tens of cross-references, you may find Contexted a very helpful solution to your problems.

    I encourage you to try it out. It’s available on Github: Curiosum Contexted, and if you haven’t read the philosophy behind my decisions in this lib, I can once again recommend my article: Context maintainability & guidelines in Elixir & Phoenix. You can also check out this repo: Here, where I share examples of how to use Contexted in a project.

    Wishing you happy coding!

    Szymon Soppa Web Developer
    Szymon Soppa Curiosum Founder & CEO

    Read more
    on #curiosum blog