Axum: Multi-tenancy (with Hexarch) and Abstracting the Repository Layer

, 606 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:

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:

  1. run migrations in accounts
  2. all other sub-crates (any order)
  3. 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:

Now, I can share the stack of sub-crates to 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 a foreign-key reference (as needed).

Use of a transaction in this example is a “work in progress”, but can be ignored for now.

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?;

        txn.commit().await?;

        // skipped: convert to UserInfo, returned as `r` below

        Ok(r)
    }

    //...
}

The new layout

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
    │       ├── models
    │       └── models.rs
    └── shops
        ├── Cargo.toml
        ├── sqlx.toml
        └── src
            └── lib.rs

Disabling abstractions with feature gates

Normal repository types would be replaced using feature gates and the same approach can be applied here, of course they would need to be applied in tandem with service, ports, repository and adapters etc - to cover the full “hot” path.

Axum route handlers can be gated to handle their disabled state. We can now run the same binary with different feature gates enabled. Imagine,

Both connect to the same (production) DB, yet each binary will only handle a single vertical.

use postgres_interfaces::PostgresShops;
#[cfg(feature = "shops")]
use postgres_shops;
#[cfg(feature = "shops")]
pub type PostgresShopsFacade = GenericPostgresShopsFacade<postgres_shops::ShopManager>;

#[cfg(not(feature = "shops"))]
pub type PostgresShopsFacade = GenericPostgresShopsFacade<DummyPostgresShopManager>;

/// Uses the Facade structural pattern
/// Ref: https://refactoring.guru/design-patterns/facade
pub struct GenericPostgresShopsFacade<S: PostgresShops> {
    pub manager: S,
}

impl<S: PostgresShops> GenericPostgresShopsFacade<S> {
    pub const fn new(manager: S) -> Self {
        Self { manager }
    }
}

pub struct DummyPostgresShopManager;
impl PostgresShops for DummyPostgresShopManager {}

Potential Caveats

sqlx’s query!() macros can only resolve their schema from within the sub-crates. Integration tests in the host application can navigate the schema if you use query() (non-compile time version).

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 I named the article as such as may extend this in the future with code examples of Axum handlers etc.

#rust #hexarch #axum