Rust Microservices Architecture Patterns

Design patterns for building scalable microservices in Rust
Jesús Pérez·
rustmicroservicesarchitecturepatterns

Rust Microservices Architecture Patterns

Microservices architecture has become the go-to pattern for building scalable, maintainable applications. Rust’s unique characteristics—memory safety, zero-cost abstractions, and excellent performance—make it an ideal choice for microservices development. This article explores proven patterns for building robust microservices architectures using Rust.

Why Rust for Microservices?

Rust offers several advantages for microservices:

  • Performance: Near C/C++ performance with memory safety
  • Concurrency: Excellent async/await support with tokio
  • Resource Efficiency: Low memory footprint and CPU usage
  • Reliability: Compile-time guarantees reduce runtime errors
  • Ecosystem: Rich ecosystem of web frameworks and libraries

Core Architecture Patterns

1. Service Registry Pattern

Implement service discovery using a centralized registry:

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::sync::RwLock;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceInstance {
    pub id: Uuid,
    pub name: String,
    pub host: String,
    pub port: u16,
    pub health_check_url: String,
    pub metadata: HashMap<String, String>,
}

pub struct ServiceRegistry {
    services: RwLock<HashMap<String, Vec<ServiceInstance>>>,
}

impl ServiceRegistry {
    pub fn new() -> Self {
        Self {
            services: RwLock::new(HashMap::new()),
        }
    }

    pub async fn register(&self, instance: ServiceInstance) {
        let mut services = self.services.write().await;
        services
            .entry(instance.name.clone())
            .or_insert_with(Vec::new)
            .push(instance);
    }

    pub async fn discover(&self, service_name: &str) -> Option<Vec<ServiceInstance>> {
        let services = self.services.read().await;
        services.get(service_name).cloned()
    }

    pub async fn deregister(&self, service_name: &str, instance_id: Uuid) {
        let mut services = self.services.write().await;
        if let Some(instances) = services.get_mut(service_name) {
            instances.retain(|instance| instance.id != instance_id);
        }
    }
}

2. Circuit Breaker Pattern

Protect services from cascading failures:

use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;

#[derive(Debug, PartialEq)]
pub enum CircuitState {
    Closed,
    Open,
    HalfOpen,
}

pub struct CircuitBreaker {
    state: Arc<Mutex<CircuitBreakerState>>,
    failure_threshold: u32,
    timeout: Duration,
}

struct CircuitBreakerState {
    state: CircuitState,
    failure_count: u32,
    last_failure_time: Option<Instant>,
    success_count: u32,
}

impl CircuitBreaker {
    pub fn new(failure_threshold: u32, timeout: Duration) -> Self {
        Self {
            state: Arc::new(Mutex::new(CircuitBreakerState {
                state: CircuitState::Closed,
                failure_count: 0,
                last_failure_time: None,
                success_count: 0,
            })),
            failure_threshold,
            timeout,
        }
    }

    pub async fn call<F, T, E>(&self, operation: F) -> Result<T, CircuitBreakerError<E>>
    where
        F: FnOnce() -> Result<T, E>,
    {
        let mut state = self.state.lock().await;

        match state.state {
            CircuitState::Open => {
                if let Some(last_failure) = state.last_failure_time {
                    if last_failure.elapsed() > self.timeout {
                        state.state = CircuitState::HalfOpen;
                        state.success_count = 0;
                    } else {
                        return Err(CircuitBreakerError::CircuitOpen);
                    }
                }
            }
            CircuitState::HalfOpen => {
                if state.success_count >= 3 {
                    state.state = CircuitState::Closed;
                    state.failure_count = 0;
                }
            }
            CircuitState::Closed => {}
        }

        drop(state);

        match operation() {
            Ok(result) => {
                self.on_success().await;
                Ok(result)
            }
            Err(error) => {
                self.on_failure().await;
                Err(CircuitBreakerError::OperationFailed(error))
            }
        }
    }

    async fn on_success(&self) {
        let mut state = self.state.lock().await;
        match state.state {
            CircuitState::HalfOpen => {
                state.success_count += 1;
            }
            CircuitState::Closed => {
                state.failure_count = 0;
            }
            _ => {}
        }
    }

    async fn on_failure(&self) {
        let mut state = self.state.lock().await;
        state.failure_count += 1;
        state.last_failure_time = Some(Instant::now());

        if state.failure_count >= self.failure_threshold {
            state.state = CircuitState::Open;
        }
    }
}

#[derive(Debug)]
pub enum CircuitBreakerError<E> {
    CircuitOpen,
    OperationFailed(E),
}

3. Event-Driven Communication

Implement asynchronous messaging between services:

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::sync::broadcast;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
    pub id: Uuid,
    pub event_type: String,
    pub source: String,
    pub timestamp: chrono::DateTime<chrono::Utc>,
    pub data: serde_json::Value,
}

pub struct EventBus {
    senders: HashMap<String, broadcast::Sender<Event>>,
}

impl EventBus {
    pub fn new() -> Self {
        Self {
            senders: HashMap::new(),
        }
    }

    pub fn subscribe(&mut self, event_type: &str) -> broadcast::Receiver<Event> {
        let sender = self
            .senders
            .entry(event_type.to_string())
            .or_insert_with(|| broadcast::channel(1000).0);

        sender.subscribe()
    }

    pub async fn publish(&mut self, event: Event) -> Result<(), Box<dyn std::error::Error>> {
        if let Some(sender) = self.senders.get(&event.event_type) {
            sender.send(event)?;
        }
        Ok(())
    }
}

// Usage example
pub struct OrderService {
    event_bus: EventBus,
}

impl OrderService {
    pub async fn create_order(&mut self, order_data: serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
        // Create order logic here

        let event = Event {
            id: Uuid::new_v4(),
            event_type: "order.created".to_string(),
            source: "order-service".to_string(),
            timestamp: chrono::Utc::now(),
            data: order_data,
        };

        self.event_bus.publish(event).await?;
        Ok(())
    }
}

Data Management Patterns

Database Per Service

Each microservice should own its data:

use sqlx::{PgPool, Row};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct User {
    pub id: i32,
    pub email: String,
    pub name: String,
}

pub struct UserRepository {
    pool: PgPool,
}

impl UserRepository {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }

    pub async fn create_user(&self, email: &str, name: &str) -> Result<User, sqlx::Error> {
        let row = sqlx::query!(
            "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name",
            email,
            name
        )
        .fetch_one(&self.pool)
        .await?;

        Ok(User {
            id: row.id,
            email: row.email,
            name: row.name,
        })
    }

    pub async fn find_by_id(&self, id: i32) -> Result<Option<User>, sqlx::Error> {
        let row = sqlx::query!("SELECT id, email, name FROM users WHERE id = $1", id)
            .fetch_optional(&self.pool)
            .await?;

        Ok(row.map(|r| User {
            id: r.id,
            email: r.email,
            name: r.name,
        }))
    }
}

Saga Pattern for Distributed Transactions

Implement compensating transactions:

use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;

#[async_trait]
pub trait SagaStep {
    async fn execute(&self, context: &mut SagaContext) -> Result<Value, SagaError>;
    async fn compensate(&self, context: &SagaContext) -> Result<(), SagaError>;
}

pub struct SagaContext {
    pub data: HashMap<String, Value>,
    pub completed_steps: Vec<String>,
}

pub struct Saga {
    steps: Vec<(String, Box<dyn SagaStep>)>,
}

impl Saga {
    pub fn new() -> Self {
        Self { steps: Vec::new() }
    }

    pub fn add_step(mut self, name: String, step: Box<dyn SagaStep>) -> Self {
        self.steps.push((name, step));
        self
    }

    pub async fn execute(&self) -> Result<SagaContext, SagaError> {
        let mut context = SagaContext {
            data: HashMap::new(),
            completed_steps: Vec::new(),
        };

        for (step_name, step) in &self.steps {
            match step.execute(&mut context).await {
                Ok(result) => {
                    context.data.insert(step_name.clone(), result);
                    context.completed_steps.push(step_name.clone());
                }
                Err(error) => {
                    // Compensate completed steps in reverse order
                    for completed_step_name in context.completed_steps.iter().rev() {
                        if let Some((_, completed_step)) = self
                            .steps
                            .iter()
                            .find(|(name, _)| name == completed_step_name)
                        {
                            let _ = completed_step.compensate(&context).await;
                        }
                    }
                    return Err(error);
                }
            }
        }

        Ok(context)
    }
}

#[derive(Debug)]
pub enum SagaError {
    StepFailed(String),
    CompensationFailed(String),
}

Security Patterns

JWT Token Validation

use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,
    pub exp: usize,
    pub iat: usize,
    pub roles: Vec<String>,
}

pub struct JwtValidator {
    decoding_key: DecodingKey,
    validation: Validation,
}

impl JwtValidator {
    pub fn new(secret: &[u8]) -> Self {
        let mut validation = Validation::new(Algorithm::HS256);
        validation.required_spec_claims = ["exp", "iat", "sub"].iter().map(|s| s.to_string()).collect();

        Self {
            decoding_key: DecodingKey::from_secret(secret),
            validation,
        }
    }

    pub fn validate_token(&self, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
        let token_data = decode::<Claims>(token, &self.decoding_key, &self.validation)?;
        Ok(token_data.claims)
    }
}

Monitoring and Observability

Distributed Tracing

use tracing::{info, instrument, Span};
use tracing_opentelemetry::OpenTelemetrySpanExt;

pub struct OrderService {
    user_service_client: UserServiceClient,
    payment_service_client: PaymentServiceClient,
}

impl OrderService {
    #[instrument(skip(self))]
    pub async fn process_order(&self, order_id: &str, user_id: &str) -> Result<(), ServiceError> {
        let span = Span::current();
        span.set_attribute("order.id", order_id.to_string());
        span.set_attribute("user.id", user_id.to_string());

        info!("Starting order processing for order: {}", order_id);

        // Validate user
        let user = self.user_service_client.get_user(user_id).await?;
        span.set_attribute("user.email", user.email.clone());

        // Process payment
        let payment_result = self.payment_service_client
            .process_payment(&user.payment_method)
            .await?;

        span.set_attribute("payment.transaction_id", payment_result.transaction_id);

        info!("Order processed successfully: {}", order_id);
        Ok(())
    }
}

Testing Strategies

Integration Testing with TestContainers

#[cfg(test)]
mod tests {
    use super::*;
    use testcontainers::{clients::Cli, images::postgres::Postgres, Container};
    use sqlx::PgPool;

    async fn setup_database() -> (Cli, Container<'_, Postgres>, PgPool) {
        let docker = Cli::default();
        let postgres_container = docker.run(Postgres::default());

        let connection_string = format!(
            "postgres://postgres:postgres@127.0.0.1:{}/postgres",
            postgres_container.get_host_port_ipv4(5432)
        );

        let pool = PgPool::connect(&connection_string).await.unwrap();

        // Run migrations
        sqlx::migrate!("./migrations").run(&pool).await.unwrap();

        (docker, postgres_container, pool)
    }

    #[tokio::test]
    async fn test_user_repository() {
        let (_docker, _container, pool) = setup_database().await;
        let repo = UserRepository::new(pool);

        let user = repo.create_user("test@example.com", "Test User").await.unwrap();
        assert_eq!(user.email, "test@example.com");

        let found_user = repo.find_by_id(user.id).await.unwrap().unwrap();
        assert_eq!(found_user.email, user.email);
    }
}

Deployment Patterns

Health Checks

use axum::{http::StatusCode, response::Json, routing::get, Router};
use serde_json::{json, Value};

async fn health_check() -> Result<Json<Value>, StatusCode> {
    // Check database connectivity, external services, etc.
    Ok(Json(json!({
        "status": "healthy",
        "timestamp": chrono::Utc::now(),
        "version": env!("CARGO_PKG_VERSION")
    })))
}

async fn readiness_check() -> Result<Json<Value>, StatusCode> {
    // Check if service is ready to accept traffic
    Ok(Json(json!({
        "status": "ready",
        "timestamp": chrono::Utc::now()
    })))
}

pub fn create_health_router() -> Router {
    Router::new()
        .route("/health", get(health_check))
        .route("/ready", get(readiness_check))
}

Conclusion

Building microservices with Rust requires careful consideration of architectural patterns. The patterns covered in this article provide a solid foundation for creating scalable, maintainable microservices:

  1. Service Discovery: Essential for dynamic service communication
  2. Circuit Breakers: Prevent cascading failures
  3. Event-Driven Architecture: Enable loose coupling between services
  4. Saga Pattern: Handle distributed transactions safely
  5. Security: Implement proper authentication and authorization
  6. Observability: Monitor and trace service interactions

Rust’s type system and performance characteristics make it an excellent choice for microservices, especially when combined with these proven architectural patterns.


Questions or feedback? Feel free to reach out on LinkedIn or GitHub.