elixir

Advanced Multi-tenancy for Elixir Applications Using Ecto

Aestimo Kirina

Aestimo Kirina on

Advanced 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!

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 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
AppSignal monitors your apps