Diving into "Clean Architecture" in Rust will take you on a journey into
DDD (Domain-Driven Design), Hexagonal design, and other variants.The goal is generally similar across both -- create abstractions to separate application logic from request handlers and said logic from interacting with a backend (or multiple backends).
Here's a sequence diagram to make this clearer. The request handler accesses the DB via the service.

Sequence diagram - this is hard on the eyes so right-click > Save and view this offline
Since there are some legacy aspects to this code-base, the user lookup should also be done via a user service; let's just add this to the "technical debt" pile for now.
The stack I'm working on is
Axum (+Tokio), sqlx, and PostgreSQL. In this particular example the request handler is not exposed to calls to the DB at all, and is isolated from this. To create this abstraction we leverage compile-time polymorphism and Rust's Trait system.We are abstracting behaviour behind a trait but the Rust compiler
monomorphizes functions to create concrete types under the hood for every type provided. This results in a compile time cost, but there is no run-time overhead.I wrote this snippet today and what's interesting to note is that our request handler doesn't refer to any
sqlx or DB related calls directly. Instead we use ctx to access ApiContext which is passed as state via Axum's middleware.This struct lets us access a
partner_ambassador_service which is implemented via a Trait, acting as an interface. Type erasure in the context of this trait, establishes a get function and hides all the logic from this point onwards that deals with calling to the DB.pub async fn get_optional_referrer(
Path(uuid): Path<uuid::Uuid>,
auth_user: AuthUser,
ctx: State<ApiContext>,
) -> Result<Json<Option<MyReferrerWithCompanyName>>, CustomError> {
// ... skip
// Check if referrer is a partner (company)
if (user_info.level & UserRole::PARTNER_AMBASSADOR.bits()) != 0 {
// Add company_name details in the JSON returned
if let Some(PartnerAmbassador::Company(ca)) = ctx
.partner_ambassador_service
.get(uid::Uid::from(uuid))
.await
.map_err(|err| {
CustomError::ErrorMessage(format!(
"Failed to fetch PartnerAmbassador ({uuid}): {err}: {err:?}"
))
})?
{
if let Some(mut inner) = dto {
inner.company_name = Some(ca.company_name);
return Ok(Json(Some(inner)));
}
};
};
// ... skip
}Notice that we have another level of abstraction at play, a
PartnerAmbassadorRepository
pub trait PartnerAmbassadorRepository: Send + Sync {
fn get(
&self,
user_id: UserUid,
) -> impl Future<Output = Result<Option<PartnerAmbassador>, PartnerAmbassadorRepositoryError>> + Send;
// snip...
}Our service looks like
#[derive(Clone)]
pub struct PartnerAmbassadorService<R: PartnerAmbassadorRepository> {
repository: R,
}
impl<R: PartnerAmbassadorRepository> PartnerAmbassadorService<R> {
pub fn new(repository: R) -> Self {
Self { repository }
}
pub async fn get(
&self,
user_id: UserUid,
) -> Result<Option<PartnerAmbassador>, PartnerAmbassadorRepositoryError> {
Ok(self.repository.get(user_id).await?)
}
}
You might be wondering, what exactly is
ctx .partner_ambassador_service though? This is created with let partner_ambassador_service = PartnerAmbassadorService::new(database.clone());where
database is let database = PostgresDatabase::new_default(DbDefaultConfig {
pool: db.clone(),
// snip ...
});If you refer back to our service, what is this
self.repository.get(user_id).await? calling? This is because I haven't shown you where PartnerAmbassadorRepository is implemented. In the example below I've redacted the actual SQL query, yet this demonstrates the abstraction at play here.impl PartnerAmbassadorRepository for PostgresDatabase {
async fn get(
&self,
user_id: UserUid,
) -> Result<Option<PartnerAmbassador>, PartnerAmbassadorRepositoryError> {
let row = sqlx::query_as!(
PostgresPartnerAmbassador,
r#"
// snip ...
"#,
self.encryption_key,
uuid::Uuid::from(user_id),
)
.fetch_optional(&self.pool)
.await
.context("failed to get company ambassador")?;
let company_ambassador = row.map(TryInto::try_into).transpose()?;
Ok(company_ambassador)
}
}