Elixir

Advanced Multi-tenancy for Elixir Applications Using Ecto

Advanced Multi-tenancy for Elixir Applications Using Ecto

This post is part of the Building a Multi-tenant Elixir Application series

  1. 1Setting Up a Multi-tenant Phoenix App for Elixir
  2. 2Advanced Multi-tenancy for Elixir Applications Using Ecto

Welcome to part two of this series. In the previous tutorial, we learned about multi-tenancy, including different multi-tenancy implementation strategies. We also started building a multi-tenant Phoenix link shortening app and added basic user authentication.

In this final part of the series, we'll build the link resource, associate users to links, and set up the redirect functionality in our app. As we finalize the app build, we'll learn about features (like Ecto custom types) that make Phoenix such a powerful framework for Elixir.

Let's now turn our attention to link shortening.

As a reminder, here's how link shortening will happen in our app:

  • Logged-in user enters a long URL
  • A random string is generated and associated with the long URL
  • Whenever this short URL is visited, the app will keep a tally of the number of visits

We'll get started by generating a controller, schema, migration, and views for a Link resource:

Shell
mix phx.gen.html Links Link links url:string visits:integer account_id:references:accounts

This should generate most of the boilerplate code we need to work with the Link resource.

You may notice that we haven't included a field for the short random string referencing the long URL. We'll cover this next.

Creating Short URLs Using the Ecto Custom Type in Phoenix

By default, Ecto models have an id field used as a model's primary key. Usually, it's in the form of an integer or, in some cases, a binary ID. In either case, when present in a model, this field is automatically incremented for every additional model created in an app.

A core function of our app is generating short, unique strings to represent long URLs. We could write a custom function to generate such strings, but since we are on a quest to learn Elixir, let's use a more creative approach.

Let's substitute the id primary key in the Link model with a custom Ecto type called HashId.

Using this approach, we'll:

  • Learn how to create and use Elixir custom types.
  • Automatically generate unique string hashes to form the short URL field for links. Ecto will manage the process whenever a new link model is created.

First, create a new file to represent this new type:

Elixir
# lib/ecto/hash_id.ex
 
defmodule Urlbot.Ecto.HashId do
  @behaviour Ecto.Type
  @hash_id_length 8
 
  # Called when creating an Ecto.Changeset
  @spec cast(any) :: Map.t
  def cast(value), do: hash_id_format(value)
 
  # Accepts a value that has been directly placed into the ecto struct after a changeset
  @spec dump(any) :: Map.t
  def dump(value), do: hash_id_format(value)
 
  # Changes a value from the default type into the HashId type
  @spec load(any) :: Map.t
  def load(value), do: hash_id_format(value)
 
  # A callback invoked by autogenerate fields
  @spec autogenerate() :: String.t
  def autogenerate, do: generate()
 
 # The Ecto type that is being converted
  def type, do: :string
 
  @spec hash_id_format(any) :: Map.t
  def hash_id_format(value) do
    case validate_hash_id(value) do
      true -> {:ok, value}
      _ -> {:error, "'#{value}' is not a string"}
    end
  end
 
  # Validation of given value to be of type "String"
  def validate_hash_id(string) when is_binary(string), do: true
  def validate_hash_id(_other), do: false
 
  # The function that generates a HashId
  @spec generate() :: String.t
  def generate do
    @hash_id_length
    |> :crypto.strong_rand_bytes()
    |> Base.url_encode64
    |> binary_part(0, @hash_id_length)
  end
end

Check out the Ecto custom types documentation, which covers the subject in a much more exhaustive way than we could in this tutorial.

For now, what we've just done allows us to use the custom data type HashId when defining a field's data type.

Let's use this new data type to define the short URL field for the Link resource next.

Using the Custom Ecto Type in Phoenix

Open up the Link schema and edit it to reference the new Ecto type we've just defined:

Elixir
# lib/urlbot/links/link.ex
 
defmodule Urlbot.Links.Link do
  use Ecto.Schema
  import Ecto.Changeset
  alias Urlbot.Ecto.HashId # Add this line
 
  @primary_key {:hash, HashId, [autogenerate: true]} # Add this line
  @derive {Phoenix.Param, key: :hash} # Add this line
 
  ...
end

Before moving on, let's briefly explain the use of @derive in the code above.

In Elixir, a protocol defines an API and its specific implementations. Phoenix.Param is a protocol used to convert data structures into URL parameters. By default, this protocol is used for integers, binaries, atoms, and structs. The default key :id is usually used for structs, but it's possible to define other parameters. In our case, we use the parameter hash and make its implementation derivable to actually use it.

Let's modify the links migration to use this new parameter type:

Elixir
# priv/repo/migrations/XXXXXX_create_links.exs
 
defmodule Urlbot.Repo.Migrations.CreateLinks do
  use Ecto.Migration
 
  def change do
    create table(:links, primary_key: false) do
      add :hash, :string, primary_key: true
      add :url, :string
      add :visits, :integer
      add :account_id, references(:accounts, on_delete: :delete_all)
 
      timestamps()
    end
 
    create index(:links, [:account_id])
  end
end

Then run the migration to create the links table:

Shell
mix ecto.migrate

We now need to associate Link and Account— but before we do, let's ensure that only an authenticated user can make CRUD operations for Link.

In the router, we need to create a new pipeline to process routes that only authenticated users can access.

Go ahead and add this pipeline:

Elixir
# lib/urlbot_web/router.ex
 
defmodule UrlbotWeb.Router do
  use UrlbotWeb, :router
  use Pow.Phoenix.Router
 
  ...
 
  pipeline :protected do
    plug Pow.Plug.RequireAuthenticated, error_handler: Pow.Phoenix.PlugErrorHandler
  end
 
  ...
end

Then add the scope to handle Link routes:

Elixir
# lib/urlbot_web/router.ex
 
defmodule UrlbotWeb.Router do
  use UrlbotWeb, :router
  use Pow.Phoenix.Router
 
  ...
 
  pipeline :protected do
    plug Pow.Plug.RequireAuthenticated, error_handler: Pow.Phoenix.PlugErrorHandler
  end
 
  scope "/", UrlbotWeb do
    pipe_through [:protected, :browser]
 
    resources "/links", LinkController
  end
 
  ...
end

Now, if you try to access any of the protected routes, Pow should redirect you to a login page:

Trying to access a protected route

Our app build is progressing well. Next, let's associate Link and Account since this forms the basis of tenant separation in our app.

Take a look at the Link schema below. We want to make sure account_id is included, since this will form the link to Account:

Elixir
# lib/urlbot/links/link.ex
 
defmodule Urlbot.Links.Link do
  use Ecto.Schema
  import Ecto.Changeset
  alias Urlbot.Ecto.HashId
 
  ...
 
  schema "links" do
    field :url, :string
    field :visits, :integer
    field :account_id, :id
 
    timestamps()
  end
  ...
 
end

Next, edit the changeset block, adding account_id to the list of allowed attributes and those that will go through validation:

Elixir
# lib/urlbot/links/link.ex
 
defmodule Urlbot.Links.Link do
  use Ecto.Schema
  import Ecto.Changeset
  alias Urlbot.Ecto.HashId
 
  ...
 
  def changeset(link, attrs) do
    link
    |> cast(attrs, [:url, :visits, :account_id])
    |> validate_required([:url, :visits, :account_id])
  end
 
end

Then, edit the Account schema and add a has_many relation as follows:

Elixir
# lib/urlbot/accounts/account.ex
 
defmodule Urlbot.Accounts.Account do
  use Ecto.Schema
  import Ecto.Changeset
  alias Urlbot.Users.User
  alias Urlbot.Links.Link
 
  schema "accounts" do
    field :name, :string
 
    has_many :users, User
    has_many :links, Link # Add this line
 
    timestamps()
  end
 
  ...
 
end

We also need to edit the Link context to work with Account. Remember, scoping a resource to a user or account is the foundation of any multi-tenant structure.

Edit your file as shown below:

Elixir
# lib/urlbot/links.ex
 
defmodule Urlbot.Links do
  ...
 
  def list_links(account) do
    from(s in Link, where: s.account_id == ^account.id, order_by: [asc: :id])
    |> Repo.all()
  end
 
  def get_link!(account, id) do
    Repo.get_by!(Link, account_id: account.id, id: id)
  end
 
  def create_link(account, attrs \\ %{}) do
    Ecto.build_assoc(account, :links)
    |> Link.changeset(attrs)
    |> Repo.insert()
  end
 
  ...
 
end

Adding a Current_Account

Our edits to the Link context show that most related controller methods use the account variable. This variable needs to be extracted from the currently logged-in user's account_id and passed on to the respective controller action.

So we need to make a custom plug to add to the conn. Create a new file — lib/plugs/set_current_account.ex — and edit its content as shown below:

Elixir
# lib/plugs/set_current_account.ex
 
defmodule UrlbotWeb.Plugs.SetCurrentAccount do
  import Plug.Conn
  alias Urlbot.Repo
  alias Urlbot.Users.User
 
  def init(options), do: options
 
  def call(conn, _opts) do
    case conn.assigns[:current_user] do
      %User{} = user ->
        %User{account: account} = Repo.preload(user, :account)
        assign(conn, :current_account, account)
      _ ->
        assign(conn, :current_account, nil)
    end
  end
end

Next, let's use this new plug by adding it to the router (specifically to the protected pipeline since user sessions are handled there):

Elixir
# lib/urlbot_web/router.ex
 
defmodule UrlbotWeb.Router do
  use UrlbotWeb, :router
  use Pow.Phoenix.Router
 
  ...
 
  pipeline :protected do
    plug Pow.Plug.RequireAuthenticated, error_handler: Pow.Phoenix.PlugErrorHandler
    plug UrlbotWeb.Plugs.SetCurrentAccount # Add this line
  end
 
  ...
 
  scope "/", UrlbotWeb do
    pipe_through [:protected, :browser]
 
    resources "/links", LinkController
  end
 
  ...
 
end

Then, edit the Link controller to pass the current_account in every action where it's required, like so:

Elixir
# lib/urlbot_web/controllers/link_controller.ex
 
defmodule UrlbotWeb.LinkController do
  use UrlbotWeb, :controller
 
  alias Urlbot.Links
  alias Urlbot.Links.Link
 
  def index(conn, _params) do
    current_account = conn.assigns.current_account  # Add this line
    links = Links.list_links(current_account)
    render(conn, :index, links: links)
  end
  def create(conn, %{"link" => link_params}) do
    current_account = conn.assigns.current_account # Add this line
    case Links.create_link(current_account, link_params) do
      {:ok, link} ->
        conn
        |> put_flash(:info, "Link created successfully.")
        |> redirect(to: ~p"/links")
 
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
 
  def edit(conn, %{"id" => id}) do
    current_account = conn.assigns.current_account  # Add this line
    link = Links.get_link!(current_account, id)
    changeset = Links.change_link(link)
    render(conn, :edit, link: link, changeset: changeset)
  end
 
  def update(conn, %{"id" => id, "link" => link_params}) do
    current_account = conn.assigns.current_account  # Add this line
    link = Links.get_link!(current_account, id)
 
    case Links.update_link(link, link_params) do
      {:ok, link} ->
        conn
        |> put_flash(:info, "Link updated successfully.")
        |> redirect(to: ~p"/links/#{link}")
 
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, link: link, changeset: changeset)
    end
  end
 
  def delete(conn, %{"id" => id}) do
    current_account = conn.assigns.current_account  # Add this line
    link = Links.get_link!(current_account, id)
    {:ok, _link} = Links.delete_link(link)
 
    conn
    |> put_flash(:info, "Link deleted successfully.")
    |> redirect(to: ~p"/links")
  end
 
end

That's it! Now, any created Link will be associated with a currently logged-in user's account.

At this point, we have everything we need for users to create links that are properly scoped to their respective accounts. You can see this in action when you consider the list index action:

Elixir
 
defmodule UrlbotWeb.LinkController do
  use UrlbotWeb, :controller
 
  alias Urlbot.Links
  alias Urlbot.Links.Link
 
  def index(conn, _params) do
    current_account = conn.assigns.current_account
    links = Links.list_links(current_account)
    render(conn, :index, links: links)
  end
 
  ...
end

Here, a user should only see links that are associated with them.

For example, this is a list index view for one user:

First user's link index view

And this is a view for a different user (notice the logged-in user's email and the different link URLs):

Second user's link index view

Next, let's build the link redirection feature.

Let's begin by adding a new controller to handle the redirect. This controller will have one show action:

Elixir
# lib/urlbot_web/controllers/redirect_controller.ex
 
defmodule UrlbotWeb.RedirectController do
  use UrlbotWeb, :controller
 
  alias Urlbot.Links
 
  def show(conn, %{"id" => id}) do
    short_url = Links.get_short_url_link!(id)
    Links.increment_visits(short_url)
    redirect(conn, external: short_url.url)
  end
 
end

We also need to modify the Link context with a custom get function that does not reference the current user:

Elixir
# lib/urlbot/links.ex
 
defmodule Urlbot.Links do
  ...
 
  def get_short_url_link!(id) do
    Repo.get!(Link, id: id)
  end
 
  ...
 
end

Next, modify the router accordingly:

Elixir
# lib/urlbot_web/router.ex
 
defmodule UrlbotWeb.Router do
  use UrlbotWeb, :router
  use Pow.Phoenix.Router
 
  ...
 
  scope "/", UrlbotWeb do
    pipe_through :browser
 
    get "/", PageController, :home
    get "/links/:id", RedirectController, :show # Add this line
  end
 
  ...
 
end

Finally, to handle the link view visits, we'll add a custom function to the Link context:

Elixir
# lib/urlbot/links.ex
 
defmodule Urlbot.Links do
  ...
 
  def increment_visits(%Link{} = link) do
    from(s in Link, where: s.id == ^link.hash, update: [inc: [visits: 1]])
    |> Repo.update_all([])
  end
 
end

That's it! Now, when we visit a link like http://localhost:4000/links/rMbzTz3o, this should redirect to the original long URL and increment the link's view counter.

Wrapping Up

This two-part series has taken you on a journey to build a multi-tenant Elixir app using the Phoenix web framework. You've learned how to implement a simple tenant separation system using foreign keys and related models within a shared database and shared schema.

Although we've tried to be as exhaustive as possible, we couldn't possibly capture the whole app-building process, including testing, deployment options, or even the use of the amazing LiveView.

All the same, we hope this tutorial provides a foundation for you to build your very own Elixir app that helps solve serious problems for your users.

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!

This post is part of the Building a Multi-tenant Elixir Application series

  1. 1Setting Up a Multi-tenant Phoenix App for Elixir
  2. 2Advanced Multi-tenancy for Elixir Applications Using Ecto

Published

Wondering what you can do next?

  • Share this article on social media
Aestimo Kirina

Aestimo Kirina

Our guest author Aestimo is a full-stack developer, tech writer/author and SaaS entrepreneur.

All articles by Aestimo Kirina

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal