CLI/Web Service architecture. Am I doing it wrong?

Hi! I am working on my first project in Rust and I feel the way I've seen people organise code doesn't really click for me. My background is primarily in Swift and Node.js and here's an example of the structure I have in my current project.

// all these traits have methods associated to them, but they're not important
trait FileStorageService { } // is implemented by LocalFileStorage and CloudFileStorage
trait SQLService { } // I have a real version and a mock
trait MongoService { } // I have a real version and a mock
trait SecretsManager { } // is implemented by LocalSecretsManager, CloudSecretsManager
trait UsersService { } // I have a real version and a mock
trait OutputWriter { } // is implemented by a LocalFileWriter and CloudFileWriter

struct UserServiceLive {
  sql_service: Arc<dyn SQLService>,
  mongo_service: Arc<dyn MongoService>,
  logged_in_user_overwrite: Option<String>
}

impl UsersService for UserServiceLive {
  async fn get_current_user(&self) -> User {
    if let Some(user_id) = self.logged_in_user_overwrite {
      // ...
    }
    // ...
  }

  async fn get_related_users(&self) -> Vec<User> {
    let current_user = get_current_user(&self);
    // make calls to databases to get info about this users and others related ...
  }
}

struct CoreLogic {
  file_storage_service: Arc<dyn FileStorageService>,
  user_service: Arc<dyn UsersService>,
  output_writer_service: Arc<dyn OutputWriter>
}

impl CoreLogic {
  async fn generate_output(&self) {
    // dummy implementation in order to get an idea how these things are used in app
    let file = self.file_storage_service.load().await;
    let users = self.get_related_users().await;

    for user in users {
      let output = self.generate_content_for_user(user);
      self.output_writer_service.write(output).await;
    }
  }

  fn generate_content_for_user(user: &User) -> String {
    // ...
  }
}

This code is heavily simplified and also every service has it's own module.

Now to my question. Everywhere I read that structs in Rust should be treated more as data than a way to bundle methods together. My approach feels very OOP like to me, but I don't really know how to solve a few of the problems in any other way.

  1. Most of my traits are implemented by at least 2 concrete types that are used in code. The app can be configured to work offline or online using a cli parameter when it starts. However even when that's not the case (like the UserService), I don't know how to mock it inside CoreLogic if I'm not using traits. And I'd rather just mock it than try to mock SQLService and MongoService in this specific case. Is this a good approach?

  2. If I were not to use traits and just pass the parameters to functions, wouldn't it be weird to have a 2-3 parameters to each function that are just dependencies? I could create a Context struct and pass that to the functions, but at that point, isn't it like passing &self?

  3. I don't really see when I could have functions that are not part of a struct. Beside some pure functions in my code, most of them make HTTP requests, need a secret, make a database request, read or write some files. The callers of the functions should not know about all these dependencies and I want to be able to write unit tests. Integration tests using the real implementations can't be the only way.

So is this approach "bad" in the idiomatic Rust way?

Arc<dyn Trait> is okay for a shared service, assuming you really need multiple implementations and/or mocks.

The other genetic/mockable approach would be to use CoreLogic<F: FileStorageService> { file_service: F } and instantiate the appropriate one. The downside is that it will duplicate all of CoreLogic's code for every combo you use it with, so it makes sense only if you have only one version for a binary (e.g. just the real one and a mocked one in a test).

Web frameworks in Rust usually have a concept of app-wide associated data that you can use to put the services or Arc<CoreLogic> into.

I know I could use generics instead of Arc<dyn ...> and that would offer better performance as far as I understand. However I would still have just as many traits.

I guess what I'm asking is if there are better ways people achieve encapsulation and testability in Rust.

Some languages prefer dependency containers. Others can replace methods at compile time/runtime (like AOP/AspectJ in Java or Swizzling in ObjC) and they achieve better testability this way without requiring writing the code in a testable way.

It feels like I am creating too much abstractions just to be able to test my code, I think that's why my approach seems somewhat wrong to me.

My general recommendation about unit testing web services with fine granularity is "don't". Most of the mocking-based approaches I see people write end up being tautological test cases that don't actually test anything except the author's ability to copy-paste, or worse yet, the ability of the ORM/repository/DB driver to fetchUserById().

Usually, web applications don't fall apart at this level. They fail because of insufficient security/authentication, assumptions that prevent extensibility, improper modelling of the domain data/knowledge, or the converse (too much abstraction and not actually getting anything done). Or even scaling, but that's much, much farther in the future.

You would probably be much better off writing end-to-end tests that cover the possible inputs (and the expected outputs) as extensively as possible. Fire up an instance of your web server, make it talk to a real database, invoke it via a JSON API endpoint. You could even try automatically generating different sets of inputs à la QuickCheck.

As for traits: I see that you are very concerned about your usage of traits. I don't see anything wrong with abstracting the storage layer behind a trait, for example. Traits are a core tool in the language, and idiomatic Rust usually uses a lot of traits.

4 Likes

Oh, you're going to have to have a trait for every thing you abstract. Just like in OOP you need an abstract class or an interface for every thing.

Rust has no swizzling or runtime modifications to types. Do not expect anything that clever from Rust. Everything is welded shut during compile time. It's statically typed, very statically. If something can change, there's explicit syntax allowing it to change.

A different, more duck-typed approach can be to use:

#[cfg(test)]
use test_module as the_thing;

#[cfg(not(test))]
use real_module as the_thing;

struct App {
   thing: the_thing::Thing;
}
2 Likes

As a person with an "enterprise programming" OOP backend background, I've also at times struggled to express the same kinds of tests in Rust that were so "obvious" in other languages (like Java). My feeling is that the industry has largely settled on a design pattern based on service objects and object-oriented dependency injection (potentially using a DI framework). The natural thing for many Rust adopters then, is to try to directly re-use these familiar patterns, and find that it doesn't work too well.

I was and still am a bit baffled by this: Unit testing and good coverage is almost a religion in the enterprise development world. Adopting Rust would likely increase one's overall code quality, but at the same time lose the very flexible way of writing unit tests with "mockable-everything".

Mostly as an experiment (at first), I developed the entrait crate last year, with the goal of somehow bridging these two worlds. Maybe you'll find it useful. I'm not saying using something like this will be a good fit in all situations, but could be worth a look.

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.