Create a multi-tenant, whitelabel application in Elixir & Phoenix part III: Adding accounts (tenants)
In the last post in the series, we added a custom router and enabled our application to dispatch subdomain requests to separate controllers which loaded different pages based on the domain requested.
The posts in these series are:
- Part I: Working with subdomains
- Part II: Dynamic subdomain routing
- Part III: Adding accounts tenants
In this article we will introduce the concept of accounts, in this context also known as “tenants”, and see how we can load tenant-specific content (brand colour and products) based on their associated subdomain. Here’s a small demo of the end result:
What are “tenants”? (Explaining multi-tenancy)
Multi-tenancy represents a specific software arhitecture where groups of users use the same piece of software but each group has different privileges, configuration (i.e branding) and in general experiences the app differently than other groups. A group of users is referred to as a “tenant”. A tenant usually refers to an organisation. An example of a multi-tenant application is Slack. You can read more about software multi-tenancy here.
In Elixir we are very fortunate to have some great packages that allow us to add specific functionality into our apps without reinventing the wheel. This is true for adding multi-tenancy as well where we can use a package called Triplex. From Triplex’s README:
A simple and effective way to build multitenant applications on top of Ecto.
Triplex leverages database data segregation techniques (such as Postgres schemas) to keep tenant-specific data separated, while allowing you to continue using the Ecto functions you are familiar with.
Let’s take a closer look at how this looks in practice.
Adding Triplex
Head back to your app and add Triplex as a depencency in your mix.exs
file:
def deps do
[
....
{:triplex, "~> 1.3.0"}
]
end
and fetch it into the project by running the following on the command line:
mix deps.get
We also need to update our configuration to include Triplex:
# config/config.exs
config :triplex, repo: Fresco.Repo, tenant_prefix: "fresco_"
One part worth clarifying from the above is the tenant_prefix
key. This instructs Triplex to prefix any tenant name with fresco_
so a tenant with name green
would have a schema called fresco_green
. This makes it easier to identify tenant-specific schemes.
Working with tenants using Triplex
means working with different Postgres schemas, one for each tenant. The first thing we'll do is add an accounts
table in the public
schema, the default app schema which is not tenant-specifc. There we will store all the tenants and any specific configuration a tenant may have. In a rather contrived example, we will store a specific branding colour for each tenant alongside their name.
There are many pieces to go through so to speed up the process we’ll use the built in Phoenix generators, in this case the context generator, to create some of the code we need. Run the following command in the project’s directory:
mix phx.gen.context Accounts Account accounts name:string:unique brand_colour:string
The above will generate a few files for us but a couple of key ones are:
- The
Accounts
module/context which we will use to manage accounts:lib/fresco/accounts.ex
- The migration file to create the
accounts
table with two fieldsname
, which is unique, andbrand_colour
:priv/repo/migrations/20230307111651_create_accounts.exs
(the timestamp will be different)
You should see a similar output like the below:
Your migration file should have contents very similar to the below:
defmodule Fresco.Repo.Migrations.CreateAccountsTable do
use Ecto.Migration
def change do
create table(:accounts) do
add :name, :string
add :brand_colour, :string
timestamps()
end
create unique_index(:accounts, :name)
end
end
Run the migrations with mix ecto.migrate
and you should now have an accounts
table in Postgres with the two columns we specified.
We need to make one change to our Accounts
context. In the file lib/fresco/accounts.ex
change this line:
def get_account!(id), do: Repo.get!(Account, id)
to this:
def get_account!(name), do: Repo.get_by!(Account, name: name)
We’re essentially changing the field that we will be querying for an account to use the account’s name (aka tenant name).
Tenants Helper
Triplex comes with plenty of convenient functions to manage tenants. Because we will also associate a tenant with an account, we’ll create a TenantsHelper
moduel which will be responsible for creating both a tenant using Triplex and a record for that tenant in the accounts
table.
Create the following file: lib/fresco/tenants_helper.ex
Inside this file add the following code:
defmodule Fresco.TenantsHelper do
alias Fresco.Accounts
alias Fresco.Repo
def create_tenant(name, brand_colour) do
Triplex.create_schema(name, Repo, fn(tenant, repo) ->
{:ok, _} = Triplex.migrate(tenant, repo)
Repo.transaction(fn ->
with {:ok, account} <- Accounts.create_account(%{name: tenant, brand_colour: brand_colour}) do
{:ok, account}
else
{:error, error} ->
Repo.rollback(error)
end
end)
end)
end
end
There a couple of things worth mentioning in the above code. One is the lack of Triplex.create
which is what we normally call to create a tenant. That is because we want to combine the creation of a tenant with that of an account using a transaction. If one of the calls fails for whatever reason, we don't want the other one to execute. The recommended way in Triplex docs seems to have an issue so the code is adapted to the solution offered in this discussion.
With the above in place, let’s load the code in the Elixir repl with:
iex -S mix
Now, let’s create our tenants with their account records. We should use one of the subdomains we used in the previous tutorials:
iex(1)> Fresco.TenantsHelper.create_tenant("green", "#00b300")
...
iex(2)> Fresco.TenantsHelper.create_tenant("farm", "#e5e2a7")
If you connect to your running Postgres instance using psql
, you can check that you have two newly created schemas called fresco_green
and fresco_farm
with their equivalent account records with the following two commands:
-- Get all schemas
select schema_name from information_schema.schemata;
-- Get all accounts
select * from public.accounts;
You should see a similar result to the following picture:
Adding Products
Since our app around the concept of farmer’s market-like shops, the next step is to add products. Products will be tenant-specific. There are many customisations a tenant might want to do on a given product but a good example is one tenant might want to sell fresh tomatoes at a different price than another. To create tenant-specific tables, we will use Triplex’s migration generator. Run the following command:
mix triplex.gen.migration create_products_table
This will create a new migration file under a new folder called tenant_migrations
. Bar the timestamp, the path should look similar to this: priv/repo/tenant_migrations/20230306101542_create_products_table.exs
.
Inside this file let’s add the code to create the products
table:
defmodule Fresco.Repo.Migrations.CreateProductsTable do
use Ecto.Migration
def change do
create table("products") do
add :name, :string
add :price, :string
timestamps()
end
end
end
Note: In a non-toy application you will want to do things differently (i.e adding constraints etc) but to keep things simple and short we will leave it to the code above.
Run the migration with:
mix triplex.migrate
This should create two products
tables, one in each schema, as shown in the picture below:
Time to add the context and Ecto schema we’ll use to interact with the above tables. Under lib/fresco/
, create a new directory called products
and inside a file called product.ex
:
mkdir lib/fresco/products
touch lib/fresco/products/product.ex
Inside the product.ex
file add the following code:
defmodule Fresco.Products.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :price, :string
field :name, :string
timestamps()
end
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:name, :price])
|> validate_required([:name, :price])
end
end
Now let’s add the context that will use the above schema. Inside lib/fresco/
create a new file called products.ex
:
touch lib/fresco/products.ex
Add the following code inside of it:
defmodule Fresco.Products do
import Ecto.Query, warn: false
alias Fresco.Repo
alias Fresco.Products.Product
def list_products(tenant) do
Repo.all(Product, prefix: Triplex.to_prefix(tenant))
end
def create_product(tenant, attrs \\ %{}) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert(prefix: Triplex.to_prefix(tenant))
end
end
The above looks fairly standard if you look at other Elixir/Phoenix/Ecto tutorials. The key noticable difference is the fact that we’re passing the tenant (i.e the tenant’s name) as the first argument to both of our functions and inside the functions we use Triplex to get the full prefix of a tenant (i.e fresco_green
vs just green
). We do this to utilise Ecto's prefix argument that tells Ecto which schema to run the query against.
Let’s use the above to create a product for each tenant. Exit and restart the Elixir repl with iex -S mix
and run the following commands to create a product for each of our two tenants:
iex(1)> Fresco.Products.create_product("green", %{name: "Fresh Tomatoes", price: "2.99"})
...
iex(2)> Fresco.Products.create_product("farm", %{name: "Fresh Tomatoes", price: "3.99"})
Going back into our Postgres repl and using psql
we can check that each table has one product by running the following psql
commands:
-- Check the products of the tenant "green"
select * from fresco_green.products;
-- Check the products of the tenant "farm"
select * from fresco_farm.products;
You should see a similar output to this:
Showing Products
For the final strech, we will update our web resources to add an endpoint which we can visit and see the products for each tenant. Let’s start with by adding the endpoint in the subdomain router file lib/fresco_web/subdomain_router.ex
:
get "/", PageController, :index
# Add the below
get "/products", ProductsController, :index
Next, let’s create the necessary controller. Create the following file inside the lib/fresco_web/subdomain/
folder:
touch lib/fresco_web/subdomain/products_controller.ex
Inside this new file we’ll add the following code:
defmodule FrescoWeb.Subdomain.ProductsController do
use FrescoWeb, :controller
alias Fresco.Products
alias Fresco.Accounts
def index(conn, _params) do
tenant = conn.private[:subdomain]
all_products = Products.list_products(tenant)
brand_colour = Accounts.get_account!(tenant).brand_colour
render(
conn,
"index.html",
%{
subdomain: tenant,
products: all_products,
style: "background-color: #{brand_colour};"
}
)
end
end
The first thing we do in the index
action is to grab the subdomain which also indicates the tenant making the request. We then grab all the products for that tenant. We also fetch the specific account related to that tenant and grab the brand colour associated with it. We then pass this information to our template.
Note I: You’ll notice that we’re passing CSS styling to the template. This is not something I would do in a real-world project but it helps keep the example simple 🙂
Note II: Triplex also provides a plug which helps you extract the tenant from the request. See the docs for this.
Let’s create the view and necessary template for the above controller and action:
# Create ProductsView file
touch lib/fresco_web/views/subdomain/products_view.ex
# Create products template folder
mkdir lib/fresco_web/templates/subdomain/products
# Create products index template
touch lib/fresco_web/templates/subdomain/products/index.html.heex
The code for the view is minimal:
# lib/fresco_web/views/subdomain/products_view.ex
defmodule FrescoWeb.Subdomain.ProductsView do
use FrescoWeb, :view
end
And here’s the code for the index template:
<section class="phx-hero" style={@style}>
<h1><%= gettext "Welcome to %{name}!", name: @subdomain %></h1>
<p>Peace of mind from prototype to production</p>
</section>
<section class="row">
<article class="column">
<h2>Products</h2>
<ul>
<%= for product <- @products do %>
<li>
Product: <%= product.name %> | Price: <%= product.price %>
</li>
<% end %>
</ul>
</article>
</section>
Party time
If you have followed all the way through, now comes the payoff. Start the Phoenix server with iex -S mix phx.server
. Open two browser tabs, one pointing to http://green.fresco.com:4000/products and the other to http://farm.fresco.com:4000/products. You should be greeted by the following:
In these tabs you should see that each submdomain that maps to a different tenant loads different results. The banner background has a different colour and the products it loads are specific to that tenant.
Summary
We covered a lot of stuff so here’s a brief summary of everything we covered in these three posts:
- We showed a basic architecture of a multitenant application
- How to change local configuration to experiment with subdomains in an application locally
- Creating a custom
Plug
to extract a subdomain from an incoming request - Creating a custom router to dynamically map to different tenant-specific resources
- Using Triplex to create and manage tenants and tenant-specific resources like the
products
table - Capturing all tenants with their specific configuration in a generic
accounts
table
I hope you as the reader have found these series useful and thank you in advance for any time you have spent on them. Until next time! 👋🏼