Build a Rust project using Clean architecture and DB transactions in the same DDD bounded context

This is just an example of an (still incomplete) real-world project written in Rust using a clean architecture: GitHub - frederikhors/rust-clean-architecture-with-db-transactions.

Goals

My intent is to have an app build in 4 layers:

  • entities:

    • some call this layer "domain", not important for now, just the minimum
  • services:

    • some call this layer "use cases", this is where business logic lives (just CRUD methods for now)
  • repositories:

    • some call this layer "adapters", this is where concrete implementation of DB/cache/mail drivers lives
  • ports:

    • some call this layer "controllers or presenters", still not present and not important for now, I'm using main.rs for this

Reproduction

The issue

This issue is about the usage of a DB transaction in a service (of the same bounded context):

Expand the code
 async fn execute(&self, input: &PlayerInput) -> Result<Player, String> {
     let player = self
         .deps
         .commands_repo
         .player_update(input, &|args| {
             Box::pin(async {
                 // I want to verify if there is any place for my player before updating it by using a method like the below
                 // but I wanna check this in a DB transaction

                 // I cannot pass transaction using lambda function because in the service layer I don't want to specify which DB I'm using and wich crate

                 // So one way to do this is by passing the team in the lambda args in `PlayerUpdateLambdaArgs`.

                 // The `team` is queried using the DB transaction on the repository level
                 // but as you can imagine this is a mess: I'm writing code here and there, back and forth

                 let team = self
                     .deps
                     .queries_repo
                     .team_by_id(&input.team_id)
                     .await
                     .unwrap();

                 if let Some(team) = team {
                     if team.missing_players == 0 {
                         return Err("no place for your player!".to_string());
                     }
                 }

                 let obj = Player {
                     id: args.actual.id,
                     name: input.name.to_owned(),
                     team_id: input.team_id.to_owned(),
                 };

                 Ok(obj)
             })
         })
         .await?;

     Ok(player)
 }

As you can see I'm using a lambda function with a struct as argument because this is the only way I can fetch in the repository level the objects I need on the business logic level.

But as you can imagine the code is not linear and I have to go back & forth.

I think I should have something (but I don't know what) on the service layer to start (and commit/rollback) a DB transaction from there: but - as properly established by the rules of Clean architecture - the service layer cannot know the implementation details of the underlying levels (repositories).

I would like to use in my services something like (pseudo code):

// Start a new DB transaction now to use with the below methods

let transaction = [DONT_KNOW_HOW_PLEASE_START_A_NEW_DB_TRANSACTION]();

let team = self.repo.team_by_id(transaction, team_id).await?;

if !team.has_free_places() { return };

let mut player = self.repo.player_by_id(transaction, player_id).await?;

player.team_id = team.id;

let player = self.repo.player_update(player).await?;

Ok(player)

Is there a way to fix this?

Maybe yes and there is a project I found searching about this, but the code is too complex for me to completely understand how to do this in my project and if there is something better or even if I'm wrong and why.

The (maybe) interesting code is here: sniper/persistence.rs at master · dpc/sniper · GitHub.

Another way I found to fix this is using state machines. I created a dedicated branch with one state machine usage for the player_create method. like this:

Expand the code
// in the repository

pub struct PlayerCreate<'a> {
    tx: sqlx::Transaction<'a, sqlx::Postgres>,
    pub input: &'a PlayerInput,
}

#[async_trait::async_trait]
impl<'a> PlayerCreateTrait for PlayerCreate<'a> {
async fn check_for_team_free_spaces(&mut self, team_id: &str) -> Result<bool, String> {
let team = self::Repo::team_by_id_using_tx(&mut self.tx, team_id).await?;

        Ok(team.missing_players > 0)
    }

    async fn commit(mut self, _player: &Player) -> Result<Player, String> {
        // update the player here

        let saved_player = Player {
            ..Default::default()
        };

        self.tx.commit().await.unwrap();

        Ok(saved_player)
    }

}

#[async_trait::async_trait]
impl commands::RepoPlayer for Repo {
type PlayerCreate<'a> = PlayerCreate<'a>;

    async fn player_create_start<'a>(
        &self,
        input: &'a PlayerInput,
    ) -> Result<PlayerCreate<'a>, String> {
        let tx = self.pool.begin().await.unwrap();

        Ok(PlayerCreate { tx, input })
    }

}

// in the service

async fn execute(&self, input: &PlayerInput) -> Result<Player, String> {
let mut state_machine = self.deps.commands_repo.player_create_start(input).await?;

    if !(state_machine.check_for_team_free_spaces(&input.team_id)).await? {
        return Err("no free space available for this team".to_string());
    }

    let obj = Player {
        id: "new_id".to_string(),
        name: input.name.to_owned(),
        team_id: input.team_id.to_owned(),
    };

    let res = state_machine.commit(&obj).await?;

    Ok(res)

}

But there are two big cons to this:

  • a lot of code to write (also very repetitive);

  • the same concepts must be used and repeated both in the repository layer and in the service layer or in any case the synthesis work to be done is not profitable for business logic but only for finding an intelligent way to avoid repeating code;

  • you have to write repository methods which are very similar with the only difference that some take a db transaction as an argument and the other doesn't.

Alternative ways

Bloom legacy

I found the post: Clean and Scalable Architecture for Web Applications in Rust with the code here: GitHub - skerkour/bloom-legacy: End-to-end encrypted Notes, Files, Calendar, Contacts... for Android, IOS, Linux & MacOS - DEPRECATED.

I really like this code except for:

  1. the service layer knows about repository implementation details

  2. (and for this reason) it is impossible to change at runtime (or just to mock during tests) the DB driver.

I opened the issue: Congratulations and question · Issue #70 · skerkour/bloom-legacy · GitHub and I'm waiting for the author.

Questions

  1. Can you help me fix this issue?

  2. Can you suggest an alternative way? I'm open to everything!

Thanks in advance.

I don't think a generic transaction constitutes an implementation detail. It is very much a logical requirement for ACID-compliance.

The cleanest API I can suggest is:

  • always interact with the DB through a transaction; there should be no methods on the DB layer to directly execute queries outside an explicit transaction – that will inevitably lead to racy code and incorrect query results.
  • The transaction object should be an RAII guard that rolls back on drop unless explicitly committed; this enables automatic, correct, and convenient error handling.
  • The DB layer should be abstracted away behind a trait. This trait should be implemented by concrete DB drivers (practically). Your business logic need not know about the specific type of driver, it can be generic (or dynamically-dispatched, to taste).
4 Likes

I also tried to build a similar example project last year: rust-realworld-entrait. It uses the entrait macro to achieve low-boilerplate loose coupling.

I didn't design my database layer in a way that supports transactions. But I believe it should be possible to do so. As @H2CO3 suggested, I think this has to be designed around acquiring some kind of DB handle, and commit using that. The question is whether the repository methods inherently exists on this handle, or whether the transaction handle is just another parameter to repository methods. In the second alternative, the transaction handle has to have a specific type supported by the chosen database implementation, this tells me it should be an associated type of some trait.

I think this post has inspired me to try this out, thanks!

1 Like

@H2CO3 everything you wrote is what I think and what I wanted to express with my issue.

I just don't know how. :smile:

@audunhalland your project is interesting. What scares me is the usage of a third party crate (I would like to write code myself both 1) to learn and better understand and 2) to evolve the code with my Rust "life trip".

If you wanna try I think the best way to start is to "change" the code in the bloom legacy repo I linked in the README (bloom-legacy/bloom at v3 · skerkour/bloom-legacy · GitHub) because there the only issue is that service layer knows about sqlx (and it shouldn't).

If we can fix this that example is a nearly perfect example of clean architecture with Rust.

Is there a specific issue? I don't think we'll be able to write up a complete database abstraction layer for you in a forum post. The basic idea is something like:

trait Transaction {
    type Error: Error;

    // repository-style CRUD queries or domain-specific queries here; eg.:
    fn user_by_id(&self, id: Id<User>) -> Result<User, Self::Error>;
    fn insert_user(&self, user: User) -> Result<(), Self::Error>;
}

impl Transaction for PostgresDriver { … }
impl Transaction for SqliteDriver { … }

fn business_logic<T: Transaction>(tx: T) {
    ...
}
1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.