📓 Cabinet of Ideas

Multitenancy in Elixir a Strategic Guide Curiosum

Multitenancy in Elixir: A Strategic Guide | Curiosum #

Excerpt #

Explore the benefits and implementations of multitenancy in Elixir to transform your software architecture.


Have you ever wondered how to efficiently manage multiple clients within a single application? Or how to keep different users’ data isolated while still leveraging shared resources? Enter multi-tenancy!

Table of contents

  1. But, what is multitenancy basically?
  2. What are the types of multitenancy?
  3. Who should use multitenancy?
  4. Enough of theory for now
  5. Multi-tenancy with schema prefixes in Ecto
  6. Multi-tenancy with foreign keys
  7. Triplex - external Elixir library for managing multi-tenancy applications!
  8. Final words
  9. Questions to Consider When Evaluating Multitenancy for Your Project

This powerful concept allows you to create scalable, secure, and personalized experiences for each client without the hassle of maintaining separate systems. Imagine being able to provide each user with a tailored environment while you effortlessly control everything from one central hub. Intrigued? Let’s delve into the world of multi-tenancy and discover how it can transform your application management strategy!

But, what is multitenancy basically? #

Multi-tenancy is a software architecture where a single instance of a software application serves multiple customers often called “tenants.” Each tenant’s data and configurations are isolated from one another, even though they share the same infrastructure. This approach allows for efficient resource utilization, cost savings and simplified maintenance.

In essence, multi-tenancy means that multiple users or groups of users (tenants) can operate within the same application environment, with each tenant’s data kept separate and secure. This architecture is particularly useful for cloud-based services and SaaS (Software as a Service) applications, where scalability and resource optimization are critical.

What are the types of multitenancy? #

Multitenancy is a software architecture where a single instance of an application serves multiple customers (tenants), with each tenant’s data being isolated from others. The main types of multitenancy differ in the degree of isolation and resource sharing among tenants.

Here are the main types of multitenancy configuration with pros and cons:

  1. Shared Database, Shared Schema

    • Description: All tenants use the same database and the same schema. Data is separated by a tenant identifier (e.g., tenant_id column, or given table primary key field).
    • Advantages: Ease of management, lower operational costs, resource savings.
    • Disadvantages: Higher risk of unauthorized data access due to errors, difficulties in scaling for large tenants, and less flexibility.
  2. Shared Database, Separate Schema

    • Description: All tenants use the same database but have separate database schemas.
    • Advantages: Better data isolation between tenants, and greater flexibility in customizing schemas for individual tenant needs.
    • Disadvantages: Increased complexity in database management, challenges in scaling, and higher operational costs than shared schema.
  3. Separate Database

    • Description: Each tenant uses a separate database.
    • Advantages: The highest level of data isolation, easier customization of the database to meet specific tenant needs, and better scalability for large tenants.
    • Disadvantages: Higher operational and management costs, greater resource consumption, and more complex migrations and upgrades.
  4. Hybrid Multitenancy

    • Description: Combines elements of different types of multitenancy, e.g., shared database and schema for smaller tenants, and separate databases for larger or more demanding tenants.
    • Advantages: Flexibility in adjusting the architecture to the specific needs of different tenant groups, potential optimization of costs and resources.
    • Disadvantages: Greater architectural and management complexity, potentially higher deployment and maintenance costs.

Who should use multitenancy? #

Multitenancy is suitable for a variety of scenarios, especially for organizations and applications that aim to serve multiple customers or clients with a single software instance. Here are some key groups for whom multitenancy is beneficial:

  1. Software as a service (SaaS) Providers
  2. Government and Educational Institutions
  3. Customer Relationship Management (CRM) Systems
  4. Financial and Bank Institutions

Enough of theory for now #

After this ‘short’ introduction, let’s jump in into multitenancy in Elixir! We are going to discuss three examples:

  • usage of schema prefixes in Ecto queries,
  • usage of the primary key to gain more secure access to resources,
  • Triplex - external library to manage multi-tenant applications

Multi-tenancy with schema prefixes in Ecto #

Let’s start with the example of a multi-tenant application, where we will use schema prefixes.

What are schema prefixes? #

In relational databases such as PostgreSQL, schema prefixes allow you to logically group database objects (such as tables) under different namespaces. It might be useful in multi-tenant applications, where each tenant needs isolated data - and thanks to the schema prefixes, we can achieve isolation while still using a single database!

Implementation in Elixir #

Let’s start by creating a bare Phoenix app

curiosum@> mix phx.new schema_prefixes curiosum@> cd schema_prefixes schema_prefixes@> mix ecto.setup

and then create a migration to create our tenants’ schemas (prefixes). Later on in the article I will show, how to create prefixes with an external library usage - Triplex.

schema_prefixes@> mix ecto.gen.migration create_sample_prefixes

And let’s define the schemas

defmodule SchemaPrefixes.Repo.Migrations.CreateSamplePrefixes do use Ecto.Migration def change do execute "CREATE SCHEMA team_a" execute "CREATE SCHEMA team_b" end end

and let’s migrate it!

schema_prefixes@> mix ecto.migrate 22:14:16.793 [info] == Running 20240714195112 SchemaPrefixes.Repo.Migrations.CreateSamplePrefixes.change/0 forward 22:14:16.797 [info] execute "CREATE SCHEMA team_a" 22:14:16.802 [info] execute "CREATE SCHEMA team_b" 22:14:16.804 [info] == Migrated 20240714195112 in 0.0s

We need one more ecto migration, to create users table:

schema_prefixes@> mix ecto.gen.migration create_users

The migration looks like this:

defmodule SchemaPrefixes.Repo.Migrations.CreateUsers do use Ecto.Migration def change do Enum.each(["team_a", "team_b"], fn prefix -> create table(:users, prefix: prefix) do add :name, :string add :email, :string timestamps() end end) end end

Now, we can migrate it:

schema_prefixes@> mix ecto.migrate 22:18:33.969 [info] == Running 20240714201651 SchemaPrefixes.Repo.Migrations.CreateUsers.change/0 forward 22:18:33.973 [info] create table team_a.users 22:18:33.987 [info] create table team_b.users 22:18:33.994 [info] == Migrated 20240714201651 in 0.0s

As we can see from the output, two database tables were created successfully!

Now, we can inspect if those tables have indeed been created in our database. Let’s connect via the terminal to the PostgreSQL session, and inspect the tables:

schema_prefixes@> psql psql (14.3) Type "help" for help. curiosum=# \c schema_prefixes_dev You are now connected to database "schema_prefixes_dev" as user "curiosum". schema_prefixes_dev=# \x Expanded display is on. schema_prefixes_dev=# \dt team_a.* List of relations -[ RECORD 1 ]---- Schema | team_a Name | users Type | table Owner | postgres schema_prefixes_dev=# \dt team_b.* List of relations -[ RECORD 1 ]---- Schema | team_b Name | users Type | table Owner | postgres

They are indeed created! (just a note - \dt would return all of the tables in the default schema, which is public).

Let’s code back to the Elixir and define user schema:

defmodule SchemaPrefixes.User do use Ecto.Schema import Ecto.Changeset schema "users" do field :name, :string field :email, :string timestamps() end def changeset(user, attrs) do user |> cast(attrs, [:name, :email]) |> validate_required([:name, :email]) end end

and user context file, with some simple actions (such as ecto queries, or repo calls):

defmodule SchemaPrefixes.UserContext do import Ecto.Query alias SchemaPrefixes.Repo alias SchemaPrefixes.User def list_users(tenant) do Repo.all(User, prefix: tenant) end def get_user!(tenant, id) do Repo.get!(User, id, prefix: tenant) end def create_user(tenant, attrs \\ %{}) do %User{} |> User.changeset(attrs) |> Repo.insert(prefix: tenant) end end

(you can read more about contexts in Elixir in one of our articles here)

Let’s explain quickly what happened there - the user schema has been created as usual, and there is nothing new. The same thing applies to the user context file, but to each function, we pass as a first argument the tenant prefix. When interacting with the database we specify the schema prefix dynamically based on that passed tenant parameter.

Let’s use iex console, to check how it works. Firstly, we are going to create users in the corresponding schemas:

iex(2)> SchemaPrefixes.UserContext.create_user("team_a", %{name: "first user", email: "curiosum@team_a.com"}) [debug] QUERY OK source="users" db=9.9ms decode=1.5ms queue=6.0ms idle=468.1ms INSERT INTO "team_a"."users" ("email","name","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["curiosum@team_a.com", "first user", ~N[2024-07-14 21:05:40], ~N[2024-07-14 21:05:40]] ↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298 {:ok, %SchemaPrefixes.User{ __meta__: #Ecto.Schema.Metadata<:loaded, "team_a", "users">, id: 1, name: "first user", email: "curiosum@team_a.com", inserted_at: ~N[2024-07-14 21:05:40], updated_at: ~N[2024-07-14 21:05:40] }} iex(3)> SchemaPrefixes.UserContext.create_user("team_b", %{name: "first user", email: "curiosum@team_b.com"}) [debug] QUERY OK source="users" db=3.6ms queue=2.3ms idle=1023.3ms INSERT INTO "team_b"."users" ("email","name","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["curiosum@team_b.com", "first user", ~N[2024-07-14 21:07:02], ~N[2024-07-14 21:07:02]] ↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298 {:ok, %SchemaPrefixes.User{ __meta__: #Ecto.Schema.Metadata<:loaded, "team_b", "users">, id: 1, name: "first user", email: "curiosum@team_b.com", inserted_at: ~N[2024-07-14 21:07:02], updated_at: ~N[2024-07-14 21:07:02] }}

Let’s check in the psql console, whether the values were inserted correctly!

schema_prefixes_dev=# SELECT * FROM team_a.users; -[ RECORD 1 ]-------------------- id | 1 name | first user email | curiosum@team_a.com inserted_at | 2024-07-14 21:05:40 updated_at | 2024-07-14 21:05:40 schema_prefixes_dev=# SELECT * FROM team_b.users; -[ RECORD 1 ]-------------------- id | 1 name | first user email | curiosum@team_b.com inserted_at | 2024-07-14 21:07:02 updated_at | 2024-07-14 21:07:02

Indeed, we have two different tenants - hence, the existing entries are isolated!

Let’s check the other two functions - get and list:

iex(7)> SchemaPrefixes.UserContext.get_user!("team_a", 1) [debug] QUERY OK source="users" db=2.7ms queue=0.1ms idle=1996.9ms SELECT u0."id", u0."name", u0."email", u0."inserted_at", u0."updated_at" FROM "team_a"."users" AS u0 WHERE (u0."id" = $1) [1] ↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298 %SchemaPrefixes.User{ __meta__: #Ecto.Schema.Metadata<:loaded, "team_a", "users">, id: 1, name: "first user", email: "curiosum@team_a.com", inserted_at: ~N[2024-07-14 21:05:40], updated_at: ~N[2024-07-14 21:05:40] } iex(8)> SchemaPrefixes.UserContext.get_user!("team_b", 1) [debug] QUERY OK source="users" db=1.1ms queue=2.0ms idle=1953.2ms SELECT u0."id", u0."name", u0."email", u0."inserted_at", u0."updated_at" FROM "team_b"."users" AS u0 WHERE (u0."id" = $1) [1] ↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298 %SchemaPrefixes.User{ __meta__: #Ecto.Schema.Metadata<:loaded, "team_b", "users">, id: 1, name: "first user", email: "curiosum@team_b.com", inserted_at: ~N[2024-07-14 21:07:02], updated_at: ~N[2024-07-14 21:07:02] } iex(9)> SchemaPrefixes.UserContext.list_users("team_a") [debug] QUERY OK source="users" db=0.7ms queue=1.1ms idle=1365.9ms SELECT u0."id", u0."name", u0."email", u0."inserted_at", u0."updated_at" FROM "team_a"."users" AS u0 [] ↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298 [ %SchemaPrefixes.User{ __meta__: #Ecto.Schema.Metadata<:loaded, "team_a", "users">, id: 1, name: "first user", email: "curiosum@team_a.com", inserted_at: ~N[2024-07-14 21:05:40], updated_at: ~N[2024-07-14 21:05:40] } ] iex(10)> SchemaPrefixes.UserContext.list_users("team_b") [debug] QUERY OK source="users" db=0.7ms queue=0.9ms idle=1880.7ms SELECT u0."id", u0."name", u0."email", u0."inserted_at", u0."updated_at" FROM "team_b"."users" AS u0 [] ↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298 [ %SchemaPrefixes.User{ __meta__: #Ecto.Schema.Metadata<:loaded, "team_b", "users">, id: 1, name: "first user", email: "curiosum@team_b.com", inserted_at: ~N[2024-07-14 21:07:02], updated_at: ~N[2024-07-14 21:07:02] } ]

Nice, the users have been fetched correctly from each schema!

That looks nice - but what if I would like to add columns to existing tables? #

Let’s kick this off by creating a new migration:

schema_prefixes@> mix ecto.gen.migration add_new_column_to_users

In that case, we can not just alter the column into users table. What we have to do here instead, is to migrate data on all different tenants - in that case, we can imagine something like, that our table name will be team_a.users. So the migration will look like this:

defmodule SchemaPrefixes.Repo.Migrations.AddNewColumnToUsers do use Ecto.Migration def change do Enum.each(["team_a", "team_b"], fn prefix -> alter table(:users, prefix: prefix) do add :password, :string end end) end end

Let’s run the migration and see the result:

schema_prefixes@> mix ecto.migrate 23:22:09.460 [info] == Running 20240714211747 SchemaPrefixes.Repo.Migrations.AddNewColumnToUsers.change/0 forward 23:22:09.462 [info] alter table team_a.users 23:22:09.470 [info] alter table team_b.users 23:22:09.471 [info] == Migrated 20240714211747 in 0.0s

Neat, we have a new column in our isolated tables!

But, do I really have to pass the tenant’s name as a function argument each time…? #

The answer to this question is no! Firstly, we have to delete each tenant argument in our user context file:

def list_users() do Repo.all(User) end def get_user!(id) do Repo.get!(User, id) end def create_user(attrs \\ %{}) do %User{} |> User.changeset(attrs) |> Repo.insert() end

The second thing is to keep the value of the tenant inside the Elixir processes, and set a customizable callback inside the repository module, to fetch the current prefix:

defmodule SchemaPrefixes.Repo do use Ecto.Repo, otp_app: :schema_prefixes, adapter: Ecto.Adapters.Postgres @impl true @doc """ This callback is invoked as the entry point for all repository operations. This can be used to provide default values per operation. """ def default_options(_options) do tenant = get_tenant() if tenant, do: [prefix: tenant] end @tenant_key :tenant def set_tenant(tenant) do Process.put(@tenant_key, tenant) end def get_tenant() do Process.get(@tenant_key) end end

as we have our code defined, let’s jump into iex console again:

iex(1)> SchemaPrefixes.Repo.set_tenant("team_a") nil iex(2)> SchemaPrefixes.Repo.get_tenant() "team_a" iex(3)> SchemaPrefixes.UserContext.list_users() [debug] QUERY OK source="users" db=4.6ms decode=1.2ms queue=1.1ms idle=235.5ms SELECT u0."id", u0."name", u0."email", u0."inserted_at", u0."updated_at" FROM "team_a"."users" AS u0 [] ↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298 [ %SchemaPrefixes.User{ __meta__: #Ecto.Schema.Metadata<:loaded, "team_a", "users">, id: 1, name: "first user", email: "curiosum@team_a.com", inserted_at: ~N[2024-07-14 21:05:40], updated_at: ~N[2024-07-14 21:05:40] } ]

The prefix: tenant option has been applied on the default_options function level, and thanks to that we, as developers, do not have to remember about passing the tenant as an argument to any function anymore!

Isn’t that awesome?

Multi-tenancy with foreign keys #

Let’s explore how to implement multi-tenancy using foreign keys in Ecto.

What are foreign keys in multi-tenancy? #

Foreign keys are used to establish a link between two tables. In a multi-tenant architecture, foreign keys can be utilized to associate records with specific tenants. In this approach, the idea is that most, if not all, resources in the system belong to a given user. In other words, we add to each table a unique tenant_id (it can be e.g. user_id) foreign key. Thanks to this, we can ensure data isolation and integrity.

Implementation in Elixir #

Let’s start by creating a bare Phoenix app, in the same way as in the previous chapter:

curiosum@> mix phx.new foreign_keys curiosum@> cd foreign_keys foreign_keys@> mix ecto.setup

Next, create a migration for users table:

foreign_keys@> mix ecto.gen.migration create_users

and define the users table:

defmodule ForeignKeys.Repo.Migrations.CreateUsers do use Ecto.Migration def change do create table(:users) do add :name, :string add :email, :string timestamps() end end end

We have to also create a blog table:

foreign_keys@> mix ecto.gen.migration create_blogs
defmodule ForeignKeys.Repo.Migrations.CreateBlogs do use Ecto.Migration def change do create table(:blogs) do add :title, :string add :user_id, references(:users, on_delete: :delete_all) timestamps() end end end

and links table:

foreign_keys@> mix ecto.gen.migration create_links
defmodule ForeignKeys.Repo.Migrations.CreateLinks do use Ecto.Migration def change do create table(:links) do add :url, :string add :blog_id, references(:blogs, on_delete: :delete_all) add :user_id, references(:users, on_delete: :delete_all) timestamps() end end end

now we can use mix ecto and migrate created migrations:

foreign_keys@> mix ecto.migrate 00:22:20.959 [info] == Running 20240714221608 ForeignKeys.Repo.Migrations.CreateUsers.change/0 forward 00:22:20.962 [info] create table users 00:22:20.976 [info] == Migrated 20240714221608 in 0.0s 00:22:21.013 [info] == Running 20240714221902 ForeignKeys.Repo.Migrations.CreateBlogs.change/0 forward 00:22:21.013 [info] create table blogs 00:22:21.022 [info] == Migrated 20240714221902 in 0.0s 00:22:21.024 [info] == Running 20240714221928 ForeignKeys.Repo.Migrations.CreateLinks.change/0 forward 00:22:21.024 [info] create table links 00:22:21.027 [info] == Migrated 20240714221928 in 0.0s

our business logic is defined in the database - let’s now create Elixir schemas:

user.ex:

defmodule ForeignKeys.User do use Ecto.Schema import Ecto.Changeset schema "users" do field :name, :string field :email, :string has_many :blogs, ForeignKeys.Blog timestamps() end def changeset(user, attrs) do user |> cast(attrs, [:name, :email]) |> validate_required([:name, :email]) end end

blog.ex:

defmodule ForeignKeys.Blog do use Ecto.Schema import Ecto.Changeset schema "blogs" do field :title, :string belongs_to :user, ForeignKeys.User has_many :links, ForeignKeys.Link timestamps() end def changeset(blog, attrs) do blog |> cast(attrs, [:title, :user_id]) |> validate_required([:title, :user_id]) end end

link.ex:

defmodule ForeignKeys.Link do use Ecto.Schema import Ecto.Changeset schema "links" do field :url, :string belongs_to :blog, ForeignKeys.Blog belongs_to :user, ForeignKeys.User timestamps() end def changeset(link, attrs) do link |> cast(attrs, [:url, :blog_id, :user_id]) |> validate_required([:url, :blog_id, :user_id]) end end

and let’s define contexts:

user_context.ex:

defmodule ForeignKeys.UserContext do import Ecto.Query, warn: false alias ForeignKeys.Repo alias ForeignKeys.User def list_users do Repo.all(User) end def get_user!(id), do: Repo.get!(User, id) def create_user(attrs \\ %{}) do %User{} |> User.changeset(attrs) |> Repo.insert() end def update_user(%User{} = user, attrs) do user |> User.changeset(attrs) |> Repo.update() end def delete_user(%User{} = user) do Repo.delete(user) end end

blog_context.ex:

defmodule ForeignKeys.BlogContext do import Ecto.Query, warn: false alias ForeignKeys.Repo alias ForeignKeys.Blog def list_user_blogs(user_id) do Repo.all(from b in Blog, where: b.user_id == ^user_id) end def create_blog(user_id, attrs \\ %{}) do %Blog{} |> Blog.changeset(Map.put(attrs, :user_id, user_id)) |> Repo.insert() end end

link_context.ex:

defmodule ForeignKeys.LinkContext do import Ecto.Query, warn: false alias ForeignKeys.Repo alias ForeignKeys.Link def list_blog_links_by_blog_id(blog_id) do Repo.all(from l in Link, where: l.blog_id == ^blog_id) end def list_blog_links_by_user_id(user_id) do Repo.all(from l in Link, where: l.user_id == ^user_id) end def create_link(user_id, blog_id, attrs \\ %{}) do %Link{} |> Link.changeset(Map.merge(attrs, %{user_id: user_id, blog_id: blog_id})) |> Repo.insert() end end

Okay, what’s going on here? We have defined very basic functions in our context modules, such as CRUD actions (create, read, update, delete). The surprising thing here is the connection between link and user - normally we would associate the link table, just with the blogs (and then, by joins, get given links by user). But as stated at the very beginning of this chapter, this is intentional! We can fetch all of the links by user_id!

Let’s talk about the advantages and disadvantages of this approach:

Pros #

  • data isolation - foreign keys ensure that each record is associated with a specific tenant, preventing data leakage between tenants. This isolation makes the data store more secure and reliable,
  • simplifies queries - filtering records by tenant_id becomes straightforward, making it easy to retrieve information specific to a tenant,
  • ease of implementation - adding a tenant_id to tables and establishing foreign key relationships is a relatively simple and well-understood approach. Configuring the database schema this way ensures that each tenant’s data is properly stored and managed,
  • performance - thanks to having tenant_id in each table, we can omit multiple join statements, which can lead to query speed-up.

Cons #

  • redundancy - including tenant_id in many tables can lead to huge redundancy, which might require additional storage space,
  • the potential of data inconsistencies - if not carefully managed, there is a risk of data inconsistencies (orphaned records, mismatched tenant IDs, missing foreign keys constraints, or inconsistent deletions),
  • scalability - as the number of tenants grows, tables can become very large, which might impact performance (hence, solutions such as partitioning or sharding might be needed)

Triplex - external Elixir library for managing multi-tenancy applications! #

Triplex is an Elixir library, which helps manage multi-tenancy by automatically creating and handling tenant-specific PostgreSQL schemas. It simplifies tenant operations like migrations, schema creation, and switching between tenants.

Let’s create the relation between tenants and users, and for each newly created user add its own prefix.

Sample implementation in Elixir #

For the last time, let’s start by creating a bare Phoenix app:

curiosum@> mix phx.new triplex_usage curiosum@> cd triplex_usage triplex_usage@> mix ecto.setup

and let’s add and set up Triplex in our app - firstly, let’s add deps in mix.exs:

def deps do [ {:triplex, "~> 1.3.0"}, ] end

and run the following command in the terminal:

triplex_usage@> mix deps.get

as the final step, we need to add the following line in the config/config.exs file:

config :triplex, repo: TriplexUsage.Repo

and that’s for the configuration! (if you use MySQL, not PSQL as me, please refer to their documentation on how to proceed!).

Next, let’s create the tenants migration

triplex_usage@> mix ecto.gen.migration create_tenants_table
defmodule TriplexUsage.Repo.Migrations.CreateTenantsTable do use Ecto.Migration def change do create table(:tenants) do add :uuid, :string timestamps() end end end

Next, let’s define the users table - to run migrations across tenant schemas, we will use the Triplex migration alias:

triplex_usage@> mix triplex.gen.migration create_users

and magically need folder just showed in the priv directory

triplex_usage/priv/repo/tenant_migrations/20240715205517_create_users.exs

Let’s define our users schema

defmodule TriplexUsage.Repo.Migrations.CreateUsers do use Ecto.Migration def change do create table(:users) do add :name, :string add :email, :string add :tenant_id, references(:tenants, prefix: "public") timestamps() end end end

and migrate them

triplex_usage@> mix ecto.migrate && mix triplex.migrate

(check whether the result from running migrations is fine!)

Let’s define again the schema modules

defmodule TriplexUsage.User do use Ecto.Schema import Ecto.Changeset schema "users" do field :name, :string field :email, :string belongs_to(:tenant, TriplexUsage.Tenant) timestamps() end def changeset(user, attrs) do user |> cast(attrs, [:name, :email]) |> validate_required([:name, :email]) end end
defmodule TriplexUsage.Tenant do use Ecto.Schema import Ecto.Changeset schema "tenants" do field :uuid, :string timestamps() end def changeset(tenant, attrs) do tenant |> cast(attrs, [:uuid]) |> validate_required([:uuid]) end def create_tenant() do Triplex.create(Ecto.UUID.generate()) end end

and define the tenants module, in which we will handle the logic for managing the app

defmodule TriplexUsage.Tenants do alias Ecto.Multi alias TriplexUsage.Tenant alias TriplexUsage.User alias TriplexUsage.Repo @tenant_id_key :tenant_id def set_schema(schema) do Process.put(@tenant_id_key, schema) end def get_schema do Process.get(@tenant_id_key) || "public" end @doc """ Steps: 1. Create prefix/schema for given user, 2. Write the result into `tenants` table, 3. Create user and associate it with the tenant """ def create_user do generate_schema() |> Triplex.create_schema(Repo, fn schema, repo -> {:ok, _result} = Triplex.migrate(schema, repo) Multi.new() |> Multi.run(:create_tenant, create_tenant(schema)) |> Multi.run(:create_user, create_user(schema)) |> Repo.transaction() end) end defp generate_schema, do: Ecto.UUID.generate() defp create_tenant(schema) do fn _repo, _attrs -> set_schema("public") %Tenant{} |> Tenant.changeset(%{uuid: schema}) |> Repo.insert() end end defp create_user(schema) do fn _repo, %{create_tenant: %Tenant{id: tenant_id}} -> set_schema(schema) %User{} |> User.changeset(%{ name: "test user", email: "test email", tenant_id: tenant_id }) |> Repo.insert() end end end

Ok, let’s describe step by step what’s going on here:

  • set_schema and get_schema are the getter and setter for the prefix, which will be later on used in the Repo call (we already defined those in the previous examples)

  • create_user - the simple process of creating a user in our application, what we do here is:

    1. generate a new schema ID,
    2. create the tenant schema, via the Triplex.create_schema/2,
    3. migrate the tenant’s schema, via the Triplex.migrate/2,
    4. and then finally, runs the transaction to insert the tenant and corresponding to it user.

Be careful with the prefixes you use while using your repo, in our example the tenants table should be created on the “public” prefix (set_schema("public")), and the user itself on given schema (set_schema(schema))

Before testing it out, we have to specify one more thing in our repository module:

defmodule TriplexUsage.Repo do use Ecto.Repo, otp_app: :triplex_usage, adapter: Ecto.Adapters.Postgres @impl true def default_options(_operation) do [prefix: Triplex.to_prefix(TriplexUsage.Tenants.get_schema())] end end

Once we have everything, we are ready to test things out in iex terminal!

iex(1)> TriplexUsage.Tenants.create_user() {:ok, "a7aa361b-0b5c-4649-93ea-d0b6ab18e7f4"}

Let’s check what it looks like in the database tables (I am again using PSQL to query existing entries, as you wish you can use repo functions to get Elixir structs):

triplex_usage_dev=# SELECT * FROM tenants; -[ RECORD 1 ]------------------------------------- id | 9 uuid | a7aa361b-0b5c-4649-93ea-d0b6ab18e7f4 inserted_at | 2024-07-15 21:48:17 updated_at | 2024-07-15 21:48:17 triplex_usage_dev=# SELECT * FROM "a7aa361b-0b5c-4649-93ea-d0b6ab18e7f4".users; -[ RECORD 1 ]-------------------- id | 1 name | test user email | test email tenant_id | inserted_at | 2024-07-15 21:48:17 updated_at | 2024-07-15 21:48:17

Neat! Our goal has been reached!

Final words #

Multi-tenancy is a powerful tool for creating scalable, secure, and efficient applications. By isolating tenant data while sharing infrastructure, you can offer tailored environments for each client. Libraries like Triplex simplify managing tenant-specific PostgreSQL schemas, enabling seamless access to map external data accurately across tenants. This approach ensures consistent value, optimal performance, and error-free operation, even when accessed through web browsers. Embracing multi-tenancy leverages the full power of modern computers, making it an ideal solution for growing applications.

The app examples are available at github!

Happy coding, and I hope, it is not working only on my computer :D

Questions to Consider When Evaluating Multitenancy for Your Project #

  1. What is the scale of your database instance?
  2. What are your data isolation requirements?
  3. What is your budget for infrastructure and maintenance?
  4. How important is customization for each tenant?
  5. What are the security implications?
  6. What is the complexity of your existing infrastructure?

FAQ #

What is multitenancy in Elixir? #

Multitenancy in Elixir refers to a software architecture where a single instance of an application serves multiple customers (tenants), isolating their data while sharing resources.

What are the benefits of multitenancy? #

Benefits include efficient resource utilization, cost savings, simplified maintenance, scalability, and secure data isolation for different tenants.

What types of multitenancy are there? #

Types include Shared Database, Shared Schema; Shared Database, Separate Schema; Separate Database; and Hybrid Multitenancy, each with varying degrees of isolation and resource sharing.

How can multitenancy be implemented in Elixir? #

In Elixir, multitenancy can be implemented using schema prefixes in Ecto, foreign keys, or external libraries like Triplex.

What are schema prefixes in Ecto? #

Schema prefixes in Ecto allow logical grouping of database objects under different namespaces, enabling data isolation within a single database.

How to create schema prefixes in Ecto? #

Create schema prefixes by defining them in migrations and using the prefix option in Ecto queries to specify the schema.

What is the role of foreign keys in multitenancy? #

Foreign keys associate records with specific tenants, ensuring data integrity and isolation by linking each resource to a tenant identifier.

Can external libraries help with multitenancy in Elixir? #

Yes, libraries like Triplex can manage multi-tenant applications in Elixir, offering additional features and simplifications for handling multitenancy.

What are the challenges of multitenancy? #

Challenges include increased complexity in database management, potential higher operational costs, and ensuring data isolation and security.

Who benefits from multitenancy? #

Multitenancy is beneficial for SaaS providers, government and educational institutions, CRM systems, and financial and banking institutions.