DEV Community

Cover image for Setting Up a Multi-tenant Phoenix App for Elixir
Aestimo K. for AppSignal

Posted on • Originally published at blog.appsignal.com

Setting Up a Multi-tenant Phoenix App for Elixir

Apps built with Elixir can support massive scalability, real-time interactivity, great fault tolerance, and the language's syntax is actually a joy to use. Elixir is a natural fit for applications such as chat apps, data dashboard apps, and anything needed to support a large userbase.

In this article, we'll use Elixir — specifically, the web framework Phoenix — to build a multi-tenant link shortening app.

Let's get started!

What's Needed for Our Phoenix Project Built with Elixir

With the growing popularity of social media, there's been an increase in the use of URL shortening services. As a budding entrepreneur, you believe there's a market for yet another URL shortening service. And since such an app lives in between a generated short URL and the long version, it's necessary that the app be fast and scalable.

These features and more are already built into Phoenix, which makes it a great choice for this project, enabling you to stand out in the market.

Takeaways

After this tutorial, you will:

  • Understand the internal workings of an Elixir/Phoenix app (for example, how to create and use a custom Ecto type, how to handle authentication, and more).
  • Understand the concept of multi-tenancy, including the different strategies available.
  • Build a multi-tenant link shortening Elixir/Phoenix app that can be extended into something more advanced with real-world use.

To follow along with the tutorial, let's set up a few things.

Prerequisites

  • Elixir installed on your development machine. If you don't have it installed, follow this guide. For this tutorial, we're using Elixir version 1.14.0.
  • Since we'll be using Phoenix for the web app build, you'll also need to have it installed on your local development machine. We're using Phoenix version 1.7.2 in this tutorial.
  • PostgreSQL - Ensure you have PostgreSQL installed, as it's the default database used by Elixir/Phoenix apps.

With that in place, let's see what kind of app we'll be building next.

An Overview of the Phoenix App We'll Build

The app that we'll build is a simple multi-tenant URL shortening app with the following features:

  • User authentication - users can register and log in to the application.
  • Multi-tenant separation - users can create shortened links within their own separate accounts.
  • URL shortening - users can input normal links and generate shortened versions that redirect back to the original link when clicked.
  • View metrics - users can see how many times a link was clicked.

Implementing these features should add to your experience in working with Elixir and provide a good foundation to build even more complex Elixir apps.

Next, let's understand what multi-tenancy is and how it works.

An Overview of Multi-tenancy

At the most basic level, a multi-tenant app is one where the same application and database serve several tenants, with each client's data kept separate from that of other clients. There are thousands of real-world multi-tenant app examples, including AppSignal, Gmail, and others.

In our app, a user will be able to register and create shortened links that are associated with their account. Other users will also be able to sign up and create shortened links associated to them, but users won't be able to see other users' links. This is a simplified but very good example of multi-tenancy in action.

In the next section, we'll outline the different strategies a developer can use when building a multi-tenant app. It's important to point out that these strategies aren't exclusive to Elixir. Rather, think of them as universal building blocks for developing multi-tenant apps regardless of the programming language used.

Multi-tenancy Strategies

Several multi-tenancy strategies are available, including:

  • Separate databases - Here, each tenant gets their own database for complete isolation of each client's data. The beauty of this approach is that it's very scalable and great for applications where data security and isolation is key: for example, patient medical records, financial records, and other similar use cases. As you can imagine, one of the biggest disadvantages with this approach is how complex and costly it is to build and maintain apps using this strategy.
  • A shared database with separate schema - In this strategy, each client gets a separate schema within a shared database. This approach allows for a scalable system that is not too complex to build and maintain. That said, having separate schemas for each client is not something you can easily use to handle massive scale.
  • A shared database with shared schema - Here, all tenants share a common database and schema. It is a great choice for low-to-medium traffic apps and offers a convenient way to get started with multi-tenancy. Many SaaS startups are often built using this strategy. The biggest disadvantage of this strategy is that it's not built for scale or speed.
  • Hybrid strategy - A not-so-common approach where you have both the shared database and shared schema for some groups of users (say, the ones that pay the least in a SaaS app), and a common database with a separate schema for premium customers. This approach offers some level of scaling but is very complex to build and maintain.
  • Containerization - Similar to the separate databases approach, here, each tenant is provided with a completely separate and isolated app container. The obvious advantages are speed and scalability, but this is complex to build and maintain.

You now have a good overview of what strategy to use when you build your own Elixir app next time.

For this app project, we'll be using the shared database with shared schema approach and an app stack described in the next section.

Our Elixir Application Stack

To build our app project, we will use the up-and-coming Elixir stack called "PETAL", short for:

  • P - Phoenix
  • E - Elixir
  • T - Tailwind CSS
  • A - Alpine JS
  • L - Liveview - LiveView is an Elixir library that you include as a dependency in a Phoenix app to handle interactivity and other real-time flows characteristic of single-page applications (SPAs).

Since our goals are simple, employing the full PETAL framework in this case is overkill. Instead, we'll use the simple stack you get when you generate a new Phoenix web application:

  • Phoenix
  • PostgreSQL
  • HTML and Tailwind CSS

We are now ready to jump into the build, but before we do, it's necessary that we understand how link shortening actually works. This will form the basis for the steps we'll take during the build.

How Link Shortening Works

Link shortening is actually very simple. The link shortening app lies between a long URL and a generated short URL. When a visitor hits a short URL, their request is received by the app and a quick database search is done to find the matching long URL. Once that long URL is found, the visitor is redirected to it, and the link visits counter updates.

Obviously, this is a very simplified outline, but it will suffice for now. Next, let's start off our build.

Generating a New Phoenix Application

Begin by generating a new Phoenix application using the following mix command:

mix phx.new urlbot
Enter fullscreen mode Exit fullscreen mode

Then open your project directory in a text editor and edit the dev.exs file with your development environment's database settings:

# config/dev.exs

import Config

config :shorten_my_links_app, ShortenMyLinksApp.Repo,
  username: "DATABASE USERNAME", # Edit this line
  password: "DATABASE PASSWORD", # Edit this line
  hostname: "localhost",
  database: "DATABASE NAME", # Edit this line
  stacktrace: true,
  show_sensitive_data_on_connection_error: true,
  pool_size: 10

  # ...
Enter fullscreen mode Exit fullscreen mode

Create the database for the application:

mix ecto.create
Enter fullscreen mode Exit fullscreen mode

Once that is done, run mix phx.server in the terminal to compile the application and spin up an instance of the compiled app on localhost:4000, where you can see the default Phoenix home page:

Default Phoenix app homepage

Next up, let's set up the multi-tenant structure and user authentication.

Setting Up Multi-tenancy for Elixir

The tenant structure needs to be implemented as early as possible into a project build, since doing it later can result in all sorts of challenges. To put the shared database with shared schema multi-tenant strategy that we chose into actuality, we'll make use of foreign keys and related models.

Specifically, we'll have two main models: User and Account (this can also be called Organization, Team, or even Company); a User belongs to the Account model.

We'll also have a third Link model which will belong to the Account model. This way, the User model can be used exclusively for authentication purposes, while resource ownership will be handled by Account.

This structure is represented in the diagram below:

Multi-tenant account structure

With this structure in place, all resources created, updated, or deleted in the app can be scoped to a specific Account and User. And just like that, we can achieve our goal of having separate tenant resources in a shared database with shared schema setup.

An additional benefit to using this structure is that you can easily expand it to invite other users to an account as "teammates" and assign them different roles. However, we won't cover this feature in this tutorial.

Let's begin by generating the Account context.

Generating an Account Context

Generate the Account context, schema, and migration file with the command below:

mix phx.gen.context Accounts Account accounts name:string
Enter fullscreen mode Exit fullscreen mode

Then run the migration with:

mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

Next, let's move on to the User.

Building a User Context and Authentication with Elixir

The User context will be used for authentication purposes. Instead of generating the User context and trying to integrate it into an authentication flow from scratch, Elixir gives us several libraries we can use, including phx.gen.auth, Coherence, and Pow, a modular and extendable authentication library for Phoenix apps.

We first add Pow to the project's dependencies:

# mix.exs

defp deps do
  [
    # ...
    {:pow, "~> 1.0.30"}
  ]
end
Enter fullscreen mode Exit fullscreen mode

And fetch it:

mix deps.get
Enter fullscreen mode Exit fullscreen mode

Then finish by installing it:

mix pow.install
Enter fullscreen mode Exit fullscreen mode

With that, we get a User context, schema, and migration file. User authentication routes are also appended to the router.

At this point, we could run the migration, but there are a couple of changes we should make to our generated user files first to ensure they are properly related.

Adding the User and Account Relationship

Begin by adding a belongs_to association to User:

# lib/shorten_my_links_app/users/user.ex

defmodule Urlbot.Users.User do
  use Ecto.Schema
  use Pow.Ecto.Schema

  schema "users" do
    pow_user_fields()

    belongs_to :account, Urlbot.Accounts.Account # Add this line

    timestamps()
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's also modify Account to include the has_many relationship:

# lib/urlbot/accounts/account.ex

defmodule Urlbot.Accounts.Account do
  use Ecto.Schema
  import Ecto.Changeset

  schema "accounts" do
    field :name, :string

    has_many :users, Urlbot.Users.User # Add this line

    timestamps()
  end

  ...
end
Enter fullscreen mode Exit fullscreen mode

Next, let's modify the users migration to add the account_id field as a foreign key. We also need to indicate that a user will be deleted whenever their related account is deleted:

# priv/repo/migrations/XXXXXXXXXX_create_users.exs

defmodule Urlbot.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :email, :string, null: false
      add :password_hash, :string

      # Add this line
      add :account_id, references(:accounts, on_delete: :delete_all), null: false

      timestamps()
    end

    create index(:users, [:account_id]) # Add this line
    create unique_index(:users, [:email])
  end
end
Enter fullscreen mode Exit fullscreen mode

Finally, it's nice to have an Account automatically created the first time a user registers on the app.

Creating an Account on User Registration

We need a way to capture the one attribute of the Acccount model (the account_name in the user registration form) and pass this attribute to a modified user creation process which will create a related Account for us.

That sounds like a lot, but let's go step-by-step.

First, add the account_name attribute to the user registration form. Since we are working with Pow which comes with pre-built view templates, we need to generate the templates by running the command:

mix pow.phoenix.gen.templates
Enter fullscreen mode Exit fullscreen mode

This will generate Pow's view templates, but we are only interested in the user registration view for now. Edit it by adding the account_name field:

# lib/urlbot_web/controllers/pow/registration_html/new.html.heex

<div class="mx-auto max-w-sm">
  ...

  <.simple_form :let={f} for={@changeset} as={:user} action={@action} phx-update="ignore">
    ...

    <!-- Add this line -->
    <.input field={f[:account_name]} type="text" label="Account name" />

    ...
  </.simple_form>
</div>
Enter fullscreen mode Exit fullscreen mode

By the way, instead of adding the account_name like this, we can use a nested form for working with Account from User (but this method should work just as well).

Next, we want to add account_name as a virtual attribute in the User schema. The reason it's a virtual attribute is simply because it's not an attribute that is built into the User schema, but we still need to use it in there. You can read more on virtual attributes.

# lib/urlbot/users/user.ex

defmodule Urlbot.Users.User do
  use Ecto.Schema
  use Pow.Ecto.Schema

  schema "users" do
    pow_user_fields()

    # Add this line
    field :account_name, :string, virtual: true

    belongs_to :account, Urlbot.Accounts.Account

    timestamps()
  end
end
Enter fullscreen mode Exit fullscreen mode

At this point, you can run mix ecto.migrate to create the user table.

Next, we want to make sure that the virtual attribute we've just added is passed on to the User changeset, which we'll add since it's not included by default when we run the Pow generator.

We'll also add a custom private function to create an account at the same time a user is created:

# lib/shorten_my_links_app/users/user.ex

defmodule Urlbot.Users.User do
  use Ecto.Schema
  use Pow.Ecto.Schema

  # Add this line since we'll be using Ecto's changeset in this schema
  import Ecto.Changeset

  # Add this line to reference Account since it's used in the private function
  alias Urlbot.Accounts

  ...

  # Also add this block of code to add a changeset
  def changeset(user, attrs) do
    user
    |> pow_changeset(attrs)
    |> cast(attrs, [:account_name])
    |> validate_required([:account_name])
    |> create_user_account(user)
    |> assoc_constraint(:account)
  end

  # Add the custom private function
  defp create_user_account(%{valid?: true,  changes: %{account_name: account_name}} = changeset, %{account_id: nil} = _user) do
    with {:ok, account} <- Accounts.create_account(%{name: account_name}) do
      put_assoc(changeset, :account, account)
    else
      _ -> changeset
    end
  end

  defp create_user_account(changeset, _), do: changeset

end
Enter fullscreen mode Exit fullscreen mode

Let's break down what's going on. First, we define a changeset block passing in a user and the user attributes. Then we cast the virtual attribute we added, followed by a validation rule to make sure it's present. We then call the private function create_user_account (defined just below the changeset block) and finalize with an assoc_constraint which checks that the associated field, account_id, exists.

With all that done, whenever a new user registers, a connected account will be automatically created. Any link resources created by the logged-in user will always be scoped to them. For example, an index view under /links will only display links created by the user, and not list other users' links, even if they are available. This resource separation at the account or user level is the foundation of a multi-tenant structure.

Up Next: Adding Link Resources and Custom Ecto Types

We've explored how multi-tenancy works and discussed a few multi-tenancy strategies. We created a Phoenix app and set up multi-tenancy. Finally, we added accounts, users and authentication, and associated users and accounts.

In the next and final part of this series, we'll look at:

  • Generating the link resource
  • Using Ecto custom types
  • Generating a current_account
  • Assigning a current_account to links
  • Redirecting links and updating the views counter

Until then, happy coding!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Top comments (0)