How to organize modules for a Rust web service

A web service typically consists of multiple layers: A controller layer for the request handling, a service layer for the logic and a database layer for the database access.

When building a web service with Rust, I see two options for organizing the project structure:

Approach 1: Organizing per domain

|-> src
  |-> users/
    |-> mod.rs
    |-> models.rs
    |-> controller.rs
    |-> service.rs
    |-> db.rs
  |-> teams/
    |-> mod.rs
    |-> models.rs
    |-> controller.rs
    |-> service.rs
    |-> db.rs

Approach 2: Organizing per application layer

|-> src
  |-> controller/
    |-> mod.rs
    |-> user.rs
    |-> team.rs
  |-> service/
    |-> mod.rs
    |-> user.rs
    |-> team.rs
  |-> db/
    |-> mod.rs
    |-> user.rs
    |-> team.rs
  |-> models/
    |-> mod.rs
    |-> user.rs
    |-> team.rs

In both cases, the mod.rs would publicly re-export the child modules so that the module structure is flattened from the user's perspective.

The advantage with approach 1 is that all code that is logically related resides in the same module. But what if I have an API endpoint or service function called get_user_with_teams that fetches both users with their teams from the database? This function would logically belong to both the users and the teams module.

With approach 2, this problem doesn't arise because the get_user_with_teams function would be re-exported in the respective modules and simply be available via crate::service::get_user_with_teams for example. Also, different layers often work on different models - so a User would have a separate struct for the DTO, database fetching, and application logic. I feel that separating by layer makes it clearer what logic belongs to which version of a struct. However, the downside is that related logic for the same domain is now split over multiple unrelated modules.

What is the recommended approach, and how do I deal with cross-domain functions if I choose approach 1 (domain driven structure)?

2 Likes

As with everything, it depends™.

Considering the following:

  • Controllers' logic should be kept to a minimum. Their sole purpose in life should be to be a translation layer from the outside world, and any logic should be delegated to the underlying services.
  • Data and logic should be kept as close as possible. In your case, using domain-driven design.

I personally prefer a mix between (using your terms) approach 1 and approach 2:

  • Use approach 2 for controllers. Unless I have to break a controller into multiple ones, in which case I resort to approach 1 for that module.
  • Use approach 1 for services, models and DAOs.

Overall, I guess we could say that if something is simple enough, keep it under 1 file, otherwise break it into multiple files.

3 Likes

I come from a Laravel background where everything is split everywhere and you have files with a few lines of code that do one little thing all over the place (Of course you could reorganize many things to your liking but the default and encouraged way to do things is this fragmented approach). I found that very annoying to work with for no real benefit, just makes it harder to figure out where everything is latter on.

When using Rust I prefer a simpler approach, but a bit similar to option 1:

I have a routes file where I declare my endpoints, and the Controller/DB/Service I just put into a single module for each entity. For simple enough entities I just have a single file like this analysts.rs.

When I split things up I usually have a handlers file which will act as the controller itself, and create other submodules as I see fit, usually it will just look like this though, a mod.rs with type definitions, some usefull functions and validation, i.e. maybe a custom Deserialize implementation for one of my types, and the handlers file. If entities need to get anything from one another, I just reexport it directly on mod.rs and that's it.

External services will usually have a entirely separate module or crate, if they are complex enough, otherwise I'll just mush them toguether into an utils or services module.

.
└── src
    ├── errors.rs
    ├── lib.rs
    ├── routes
    │   ├── analysts.rs
    │   ├── cases
    │   │   ├── handlers.rs
    │   │   └── mod.rs
    │   ├── people
    │   │   ├── handlers.rs
    │   │   └── mod.rs
    │   ├── transformers
    │   │   ├── handlers.rs
    │   │   └── mod.rs
    ├── routes.rs
    └── sql.rs

Most importantly its what @firebits.io said. If its simple, just keep it that way ahaha.

For both approach 1 & 2, where will traits also be placed?

Clean Architecutre

I like the idea from Uncle Bob that project structure should not matter on language or framework we use. If we port from one language to the other (Java, Python, Rust, or Go), could we keep the project structure?

What is the first thing you want to see when joining a new project? If you are doing a blog post maybe you want to see things like post/ user/ in the root.

I like to have a Repository Abstraction trait. Thus unit tests for business logic in use_case.rs are fast. Compile time is fast because I don't wait for DB dependencies. @clarnx I put the traits closer to the code that uses it.

I don't use Usecase Abstraction because I like to be able to Ctrl+Click and find implementation. I think it is OK for a controller to depend on a fast usecase.

How can we subjectively measure what is better?

  • decrease compile time, increase productivity. To be in a flow state.
  • not having many folders open at the same time. So ideally you have only a package open in your IDE.
  • simple, easy to follow. Clarity and consistency.
==> model.rs <==
use std::collections::HashSet;

struct Sentence {
    words: Vec<Word>,
    understanding: f32,
}

pub(crate) struct Word;

enum Status {

==> repo_abstraction.rs <==
pub use super::model::{AppStructure, UserScore};

pub trait Repository {
    fn grammar_structures(&self) -> Vec<AppStructure>;
    fn user_scores(&self) -> Vec<UserScore>;
}

==> use_case.rs <==
use rand::prelude::IteratorRandom;
use rand::thread_rng;
use split_iter::Splittable;

use super::model::{AppStructure, UserScore, UserScoresAnalytics};
use super::repo_abstraction::Repository;

You see? No slow third-party dependencies.

I want to hide the DB implementation, and the controller somewhere deep, while keeping it external (i.e. easy to replace).


What do you think about this structure?

Details

How to avoid "defined multiple times" when importing? Should we speak about naming structs?
Names use_case.rs, repo_abstraction.rs, model.rs may be too general. Another way is to prefix them with the feature names recommender_use_case.rs, and estimator_use_case.rs.

For naming the data layer I was thinking between db/, repo/, infrastructure/, ops/, operation/, data/.

Personal experience: when working on a large cloud provider, I needed to add a new implementation. I simply created a new folder right next to the old feature. I knew where to put all the code and it was easy to follow.

2 Likes

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.