Axum: Multi-tenancy (with Hexarch) and Abstracting the Repository Layer
, 625 words, 3 minutes read
Implementing a repository layer generally looks like
impl UserRepository for PostgresDBOutbound {
async fn create(&self, creation: UserCreation) -> anyhow::Result<UserInfo> {
let user = sqlx::query_as!(
UserInfo,
r#"
INSERT INTO "users" (
"email"
) VALUES ($1)
RETURNING
"id",
"email",
"firstname",
"lastname",
"created_at",
"updated_at"
"#,
creation.email
)
.fetch_one(&self.pool)
.await
.context("could not create User")?;
Ok(user)
}
//...
}
sqlx has better multi-tenancy support (as of 5-days ago)
I noticed that sqlx had released 0.9.0-alpha.1 and mentions PR#3383 with a full example for setting up multi-tenancy with Postgres.
The above example’s file-structure has some interesting aspects to note:
- the main binary has
src/migrations. Each sub-crate has them abovesrc/lib.rs. sqlx.tomlappears in the main binary and each sub-crate.- each sub-crate maps to a Postgres schema (namespace) –
accounts.accountis the account table inside theaccountsschema; it also has it’s own_sqlx_migrationstable. - the postgres
publicschema has it’s own_sqlx_migrationstable as well.
Since accounts are a primary aspect, in the sense that not only are they are the “tenant” but also foreign-key constraints will tie back to it (in most cases), if you refer to the example’s README – migrations need to be run in a specific order:
- run migrations in
accounts - all other sub-crates (any order)
- in the main binary
.
├── Cargo.toml
├── README.md
├── accounts
│ ├── Cargo.toml
│ ├── migrations
│ │ ├── 01_setup.sql
│ │ ├── 02_account.sql
│ │ └── 03_session.sql
│ ├── sqlx.toml
│ └── src
│ └── lib.rs
├── payments
│ ├── Cargo.toml
│ ├── migrations
│ │ ├── 01_setup.sql
│ │ └── 02_payment.sql
│ ├── sqlx.toml
│ └── src
│ └── lib.rs
├── sqlx.toml
└── src
├── main.rs
└── migrations
├── 01_setup.sql
└── 02_purchase.sql
Another “abstraction” to the repository layer
daymare was just commenting on the need for constant abstractions, but this offers two benefits:
- the sub-crates only care about primitive types, as these map (more or less) to the primitive types supported by
sqlxand their Postgres equivalents. - as such sub-crate public APIs may not need to change types more often
- types used by the repository interface can change (as much as they need to), without impacting the sub-crate API.
Please note that I am using the term abstraction here rather loosely. In a strict sense, this would refer to the use of generics and traits (which we do so later on, with feature gates). The repository layer is still the last abstraction layer of significance but we are now placing a crate boundary between this layer and our sqlx sub-crates – which is also an abstraction in the sense of
- strict public API
- encapsulation
- organisation
- visibility (as needed!)
Now, I can share the stack of sub-crates with another engineer and they can have a full postgres step, which is decoupled from the host Axum application. We can design sub-systems with the knowledge that each tenant has an Account and even enforce foreign-key references (as needed).
Use of a transaction below is a placeholder, as we may create associated records in join-tables related to the account upon its creation.
impl UserRepository for PostgresDBOutbound {
async fn create(&self, creation: UserCreation) -> anyhow::Result<UserInfo> {
let accounts = AccountsManager::setup(&self.pool)
.await
.map_err(|err| anyhow!(err).context("error initializing AccountsManager"))?;
// Use externally managed transaction, as an example only
let mut txn = self.pool.begin().await?;
let account_id = accounts.create(&mut txn, &creation.email).await?;
// note: call to other sub-crates to create any records inside other postgres schema (as needed).
txn.commit().await?;
// skipped: convert to UserInfo, returned as `r` below
Ok(r)
}
//...
}
This is the layout I have adopted, with a sub-crate called postgres_interfaces – more on this below:
persistence
└── postgres
├── accounts
│ ├── Cargo.toml
│ ├── migrations
│ │ ├── 01_setup.sql
│ │ ├── 02_account.sql
│ │ └── 03_session.sql
│ ├── sqlx.toml
│ └── src
│ ├── lib.rs
│ ├── models
│ └── models.rs
├── interfaces
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
└── shops
├── Cargo.toml
├── sqlx.toml
└── src
└── lib.rs
Potential Caveats
Whilst writing an integration test (in an unrelated crate, meaning one that does not have a sqlx.toml file) seems to just work fine. Even running cargo sqlx prepare --workspace -- --all-targets picked up these changes in the integration tests.
TBD
Conclusion
I also wrote about some challenges I was facing with Postgres namespaces and that discussion lead me in this direction.
What are your thoughts in adding another layer of abstraction, would you consider this to be extreme?
P.S. this is not limited to Axum, but named the article as such as I may extend this in the future with code examples of Axum handlers etc.