Zytkowski's Thought Dumpster

The perfect Rust microservice structure

The disclaimer:

⚠️ The perfect on the title refers to my opinion over maintainability and testability, everyone's free to disagree 😉

TL;DR

Here's the code. I'm using Rocket (with Tokio runtime), SeaORM, and a "Use Case" based approach where Rocket manages configuration with it's state, and inside every route I create the necessary dependencies for the use case functions. et voila.

The journey

I've been studying Rust in-and-out for the last couple of months, and now I feel comfortable enough to show some work I've done whilst trying to understand how Rust differs from my main language, which is Kotlin, and boy oh boy, is it a different beast.

I have a background with lower-level-ish languages using Delphi, but after I moved to Kotlin I noticed a large spike in productivity and delivery quality. And, as much as people have proven JVM-based languages are fast enough to compete with Rust, I still decided to stick with it for the long run as my secondary (hopefully primary?) language.

What I wanted to build

I was pursuing a structure that is similar to what you would find on a Javalin/Ktor application structured with Hexagonal Architecture, but quickly found out that managing the layers on hexagonal architecture with Rust can become complicated. Thus, I've decided to go for a Use-Case based approach, with functional programming (which I'm still not comfortable with, but learning a lot along the way).

Anyways, things I determined as "must-haves" on the service:

The Rust stack

I've chosen Rocket as the server component because I found it a little more ergonomic than Actix or Axium, even though they are also great frameworks. For the database side of things I'm going for SeaORM, specially because of it's amazing CLI tool, and also because it wraps around SQLx, which is great for running very specialized queries in the future.

The code

I'll be showing the main components (and omitting imports) here for the sake of readability, but you can always read the full code if you want to 😁.

The folder structure:

Folder Structure

Creating the server:

// https://github.com/Incognitowski/rust-perfect-endpoints/blob/master/src/main.rs
#[launch]
async fn rocket() -> _ {
    dotenv().ok();

    let database_connection = get_connection_pool().await;

    migration::Migrator::up(&database_connection, None)
        .await
        .expect("Failed to migrate database.");

    let app_state = AppState::new(
        database_connection,
        env::var("PRICE_CONVERSION_HOST")
            .expect("Failed to load PRICE_CONVERSION_HOST environment variable"),
    );

    rocket::build().manage(app_state).mount(
        "/",
        routes![IndexRoute, ProductByIdRoute, ProductPriceQuoteRoute],
    )
}

The "Product Quote" Route:

// https://github.com/Incognitowski/rust-perfect-endpoints/blob/master/src/transport/product_price_quote_route.rs
#[get("/products/<product_id>/quote")]
pub async fn product_price_quote_route(
    _api_key: ApiKey,
    app_state: &State<AppState>,
    product_id: &str,
) -> Option<ProductQuoteResponse> {
    let database_connection = &app_state.inner().db_connection;
    let price_conversion_client = PriceConversionClient::new(
        &app_state.inner().price_conversion_host
    );
    product_price_quote_use_case(
        product_id, 
        database_connection, 
        &price_conversion_client
    ).await
}

Here we can se a clear definition of the route's responsibility: Create dependencies for the use case and invoke it. Of course here I'm responding with an Option<T> structure, which required me to implement the Responder trait for the struct.

The use case:

// https://github.com/Incognitowski/rust-perfect-endpoints/blob/master/src/business/product_price_quote_use_case.rs
pub async fn product_price_quote_use_case(
    product_id: &str,
    database_connection: &DatabaseConnection,
    price_converter: &dyn PriceConversionGateway,
) -> Option<ProductQuoteResponse> {
    let product = Product::find_by_id(product_id)
        .one(database_connection)
        .await
        .unwrap();

    let product = match product {
        None => return None,
        Some(p) => p,
    };

    let btc_quote = price_converter.convert_to_btc(&product.value).await;

    Some(ProductQuoteResponse { product, btc_quote })
}

As you can see, we only depend on a dyn reference to an implementation of our PriceConversionGateway trait, avoiding the use of any kind of Box/Pin structure. Also, because we set up the trait to as async, we can make this code share unused thread resources just like Kotlin's Coroutines.

The test:

// https://github.com/Incognitowski/rust-perfect-endpoints/blob/master/src/test/product_price_quote_use_case_test.rs
#[tokio::test]
async fn should_properly_quote_product() -> Result<(), ()> {
    let created_at = Utc::now().naive_utc();

    let mock_db = MockDatabase::new(DatabaseBackend::Postgres)
        .append_query_results([vec![product::Model {
            id: "a".to_string(),
            name: "Tennis Ball".to_string(),
            value: Decimal::new(12, 3),
            created_at,
        }]])
        .into_connection();

    assert_eq!(
        Some(ProductQuoteResponse {
            product: product::Model {
                id: "a".to_string(),
                name: "Tennis Ball".to_string(),
                value: Decimal::new(12, 3),
                created_at,
            },
            btc_quote: Decimal::new(123, 12),
        }),
        product_price_quote_use_case(
            "a", 
            &mock_db, 
            &PriceConversionGatewayMock
        ).await
    );

    Ok(())
}

#[tokio::test]
async fn should_return_none_when_not_able_to_find_product() -> Result<(), ()> {
    let mock_db = MockDatabase::new(DatabaseBackend::Postgres)
        .append_query_results([vec![] as Vec<product::Model>])
        .into_connection();

    assert_eq!(
        None,
        product_price_quote_use_case(
            "ABCDE", 
            &mock_db, 
            &PriceConversionGatewayMock
        ).await
    );

    Ok(())
}

struct PriceConversionGatewayMock;

#[async_trait]
impl PriceConversionGateway for PriceConversionGatewayMock {
    async fn convert_to_btc(&self, _usd_value: &Decimal) -> Decimal {
        Decimal::new(123, 12)
    }
}

Because our use case only depends on a database connection (which we can easily mock with a tool provided by SeaORM) and the implementation of a trait, we can simply create a struct and locally implement it's trait, and provide the behaviour our test scenarios require.

Next steps:

Like I said at the beginning: I'm discovering new techniques on a daily-basis whilst studying Rust, and I'm having a thrill with it. There are many things I'm yet to learn, like proper component testing to make sure the application is actually behaving as intented, or managing a kafka connection inside the service to leverage other means of communication I've already implemented with Kotlin.

The wrap-up

I'm no Rust pro, but I think this structure is pretty solid for production use. It allows for function extration for reusability in different use cases, as well as ease of introduction of new features and maintanability. Anyways, remember: there is no such thing as the perfect language, perfect framework, or perfect structure, every decision has it's perks and drawbacks.

#back-end #code #programming #rocket-rs #rust #sea-orm