Rust Webサービスレイヤーで堅牢なビジネスロジックを構築する
Daniel Hayes
Full-Stack Engineer · Leapcell

はじめに
進化し続けるWeb開発の状況において、スケーラブルで保守可能でテスト可能なアプリケーションを構築することは最重要です。プロジェクトの複雑さが増すにつれて、ビジネスルール、データアクセス、HTTP処理が単一のレイヤーに絡み合うと、理解、変更、テストが困難なコードにつながる可能性があります。この一般的な落とし穴は、しばしば「ファットコントローラー」または「アネミックモデル」につながり、生産性を妨げ、微妙なバグを導入します。Rustは、その強力な型システム、パフォーマンス特性、および正確性への注力により、堅牢なWebサービスを構築するための優れた基盤を提供します。しかし、Rustを使用するだけでは十分ではありません。思慮深いアーキテクチャパターンは依然として重要です。この記事では、Rust Webプロジェクトにおけるサービスレイヤーの設計と実装を掘り下げます。これは、ビジネスロジックをカプセル化し、HTTPインフラストラクチャおよびデータベースの詳細からビジネスロジックを分離する強力なパターンです。このアプローチを採用することにより、コード reorganiztion を改善し、コラボレーションを促進し、最終的により回復力のあるアプリケーションを提供することを目指します。
サービスレイヤー設計の柱の理解
Rustでサービスレイヤーを構築する詳細に入る前に、関連するコアコンセプトの共通理解を確立しましょう。
-
ビジネスロジック: これは、ビジネスがどのように機能するか、そしてデータがどのように変換および操作されるかを定義するコアルールとプロセスを指します。これは、単なるデータストレージと取得を超えるアプリケーションの「何」と「なぜ」です。例としては、ユーザー入力の検証、注文合計の計算、割引の適用、または複雑なワークフローのオーケストレーションが含まれます。
-
サービスレイヤー: サービスレイヤーは、プレゼンテーション/HTTPレイヤー(例:コントローラーまたはハンドラー)とデータアクセスレイヤー(例:リポジトリまたはORM)の中継役として機能します。その主な責任は、ビジネスロジックをカプセル化し、オーケストレーションすることです。コントローラーからのリクエストを受け取り、ビジネスルールを適用し、データレイヤーと対話し、結果を返します。アプリケーションが実行できる操作を明示的に定义します。
-
リポジトリパターン: このパターンは、基盤となるデータストレージメカニズムを抽象化します。リポジトリは、CPU (Create, Read, Update, Delete) 操作をデータ集計に対して実行するためのインターフェースを提供し、サービスレイヤーをデータベース(例:SQL、NoSQL)の具体性から分離します。これにより、サービスレイヤーは一貫したオブジェクト指向の方法でデータと対話できます。
-
依存性注入 (DI): Rustの所有権システムは、グローバル状態を自然に避けますが、DIは依存関係を管理するための貴重なパターンです。コンポーネント(サービス構造体など)が依存関係(データベース接続、リポジトリ実装、または他のサービスなど)を自分で作成するのではなく、コンポーネントに渡すことが含まれます。これにより、緩い結合が促進され、テストとリファクタリングがはるかに容易になります。
Rust Webアプリケーションでのサービスレイヤーの実装
サービスレイヤーの背後にある根本的な原則は、関心の分離です。WebハンドラーはHTTPリクエストとレスポンスの処理のみに集中し、データアクセスレイヤーはデータベースとの対話のみに集中すべきです。サービスレイヤーは、このギャップを埋め、すべてのアプリケーション固有のビジネスルールを保持します。
簡単な例でこれを図解しましょう。仮のProduct
管理アプリケーションです。Product
構造体、ProductRepository
トレイト、およびProductService
構造体を使用します。
まず、データモデルとエラータイプを定義します。
// src/models.rs #[derive(Debug, Clone, PartialEq, Eq)] pub struct Product { pub id: String, pub name: String, pub description: String, pub price: u32, pub stock: u32, } // src/errors.rs #[derive(Debug, thiserror::Error)] pub enum ServiceError { #[error("Product not found: {0}")] NotFound(String), #[error("Invalid product data: {0}")] InvalidData(String), #[error("Database error: {0}")] DatabaseError(ProductRepositoryError), #[error("Insufficient stock for product {0}. Available: {1}, Requested: {2}")] InsufficientStock(String, u32, u32), // ... potentially other errors } #[derive(Debug, thiserror::Error)] pub enum ProductRepositoryError { #[error("Failed to connect to database")] ConnectionError, #[error("Record not found")] RecordNotFound, #[error("Database operation failed: {0}")] OperationFailed(String), // ... other repository specific errors } // Convert ProductRepositoryError to ServiceError impl From<ProductRepositoryError> for ServiceError { fn from(err: ProductRepositoryError) -> Self { ServiceError::DatabaseError(err) } }
次に、ProductRepository
トレイトを定義しましょう。このトレイトは、製品リポジトリとして機能したいすべての型に対する契約を概説しており、さまざまなデータベース実装(例:PostgreSQL、MongoDB、またはテスト用のインメモリモック)を簡単に交換できます。
// src/repositories.rs use async_trait::async_trait; use crate::models::Product; use crate::errors::ProductRepositoryError; #[async_trait] pub trait ProductRepository: Send + Sync + 'static { // 'static is good practice for traits passed around async fn find_all(&self) -> Result<Vec<Product>, ProductRepositoryError>; async fn find_by_id(&self, id: &str) -> Result<Option<Product>, ProductRepositoryError>; async fn create(&self, product: Product) -> Result<Product, ProductRepositoryError>; async fn update(&self, product: Product) -> Result<Product, ProductRepositoryError>; async fn delete(&self, id: &str) -> Result<(), ProductRepositoryError>; // Method to update stock (could be part of update, but explicit is good) async fn update_stock(&self, id: &str, new_stock: u32) -> Result<(), ProductRepositoryError>; }
これで、デモンストレーションおよびテスト目的で、ProductRepository
のインメモリバージョンを実装できます。
// src/repositories.rs (continued) use std::collections::HashMap; use std::sync::{Arc, Mutex}; pub struct InMemoryProductRepository { products: Arc<Mutex<HashMap<String, Product>>〉, } impl InMemoryProductRepository { pub fn new() -> Self { let mut products_map = HashMap::new(); products_map.insert("p1".to_string(), Product { id: "p1".to_string(), name: "Laptop".to_string(), description: "Powerful portable computer".to_string(), price: 1200, stock: 10, }); products_map.insert("p2".to_string(), Product { id: "p2".to_string(), name: "Mouse".to_string(), description: "Wireless optical mouse".to_string(), price: 25, stock: 50, }); InMemoryProductRepository { products: Arc::new(Mutex::new(products_map)), } } } #[async_trait] impl ProductRepository for InMemoryProductRepository { async fn find_all(&self) -> Result<Vec<Product>, ProductRepositoryError> { let products_guard = self.products.lock().unwrap(); Ok(products_guard.values().cloned().collect()) } async fn find_by_id(&self, id: &str) -> Result<Option<Product>, ProductRepositoryError> { let products_guard = self.products.lock().unwrap(); Ok(products_guard.get(id).cloned()) } async fn create(&self, product: Product) -> Result<Product, ProductRepositoryError> { let mut products_guard = self.products.lock().unwrap(); if products_guard.contains_key(&product.id) { return Err(ProductRepositoryError::OperationFailed(format!("Product with ID {} already exists", product.id))); } products_guard.insert(product.id.clone(), product.clone()); Ok(product) } async fn update(&self, product: Product) -> Result<Product, ProductRepositoryError> { let mut products_guard = self.products.lock().unwrap(); if !products_guard.contains_key(&product.id) { return Err(ProductRepositoryError::RecordNotFound); } products_guard.insert(product.id.clone(), product.clone()); Ok(product) } async fn delete(&self, id: &str) -> Result<(), ProductRepositoryError> { let mut products_guard = self.products.lock().unwrap(); if products_guard.remove(id).is_none() { return Err(ProductRepositoryError::RecordNotFound); } Ok(()) } async fn update_stock(&self, id: &str, new_stock: u32) -> Result<(), ProductRepositoryError> { let mut products_guard = self.products.lock().unwrap(); if let Some(product) = products_guard.get_mut(id) { product.stock = new_stock; Ok(()) } else { Err(ProductRepositoryError::RecordNotFound) } } }
リポジトリを配置したら、ProductService
を定義できます。ここにビジネスロジックがあります。
// src/services.rs use std::sync::Arc; use crate::models::Product; use crate::repositories::ProductRepository; use crate::errors::ServiceError; pub struct CreateProductDto { pub id: String, pub name: String, pub description: String, pub price: u32, pub stock: u32, } pub struct UpdateProductDto { pub name: Option<String>, pub description: Option<String>, pub price: Option<u32>, pub stock: Option<u32>, } pub struct ProductService<R: ProductRepository> { repository: Arc<R>, } impl<R: ProductRepository> ProductService<R> { pub fn new(repository: Arc<R>) -> Self { ProductService { repository } } pub async fn get_all_products(&self) -> Result<Vec<Product>, ServiceError> { self.repository.find_all().await.map_err(ServiceError::from) } pub async fn get_product_by_id(&self, id: &str) -> Result<Product, ServiceError> { self.repository.find_by_id(id).await? .ok_or_else(|| ServiceError::NotFound(id.to_string())) } pub async fn create_product(&self, dto: CreateProductDto) -> Result<Product, ServiceError> { // Business logic: Ensure price and stock are positive if dto.price == 0 { return Err(ServiceError::InvalidData("Product price cannot be zero".to_string())); } if dto.stock == 0 { return Err(ServiceError::InvalidData("Product stock cannot be zero".to_string())); } let product = Product { id: dto.id, name: dto.name, description: dto.description, price: dto.price, stock: dto.stock, }; self.repository.create(product).await.map_err(ServiceError::from) } pub async fn update_product(&self, id: &str, dto: UpdateProductDto) -> Result<Product, ServiceError> { let mut product = self.repository.find_by_id(id).await? .ok_or_else(|| ServiceError::NotFound(id.to_string()))?; // Business logic: Apply updates and validate if let Some(name) = dto.name { product.name = name; } if let Some(description) = dto.description { product.description = description; } if let Some(price) = dto.price { if price == 0 { return Err(ServiceError::InvalidData("Product price cannot be zero".to_string())); } product.price = price; } if let Some(stock_update) = dto.stock { if stock_update == 0 { return Err(ServiceError::InvalidData("Product stock cannot be zero".to_string())); } product.stock = stock_update; } self.repository.update(product).await.map_err(ServiceError::from) } pub async fn delete_product(&self, id: &str) -> Result<(), ServiceError> { // Business logic check: maybe prevent deletion if product is part of an active order // For simplicity, we'll just delete for now. self.repository.delete(id).await? .map_err(|_| ServiceError::NotFound(id.to_string())) // Convert RepositoryError::RecordNotFound to ServiceError::NotFound } pub async fn order_product(&self, product_id: &str, quantity: u32) -> Result<(), ServiceError> { let mut product = self.get_product_by_id(product_id).await?; // Core business logic: Check stock before decrementing if product.stock < quantity { return Err(ServiceError::InsufficientStock(product.name, product.stock, quantity)); } product.stock -= quantity; self.repository.update_stock(&product.id, product.stock).await?; Ok(()) } }
最後に、AxumのようなWebフレームワークに接続します。
// src/main.rs use axum::{ extract::{Path, State, Json}, routing::{get, post, put, delete}, http::StatusCode, response::IntoResponse, Router, }; use std::sync::Arc; use crate::services::{ProductService, CreateProductDto, UpdateProductDto}; use crate::repositories::InMemoryProductRepository; use crate::errors::ServiceError; use crate::models::Product; mod models; mod repositories; mod services; mod errors; #[tokio::main] async fn main() { let repo = Arc::new(InMemoryProductRepository::new()); let service = ProductService::new(repo); let app = Router::new() .route("/products", get(get_all_products).post(create_product)) .route("/products/:id", get(get_product_by_id).put(update_product).delete(delete_product)) .route("/products/:id/order", post(order_product)) .with_state(Arc::new(service)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("Listening on http://0.0.0.0:3000"); axum::serve(listener, app).await.unwrap(); } type AppState = Arc<ProductService<InMemoryProductRepository>>; // HTTP handlers below async fn get_all_products( State(service): State<AppState> ) -> Result<Json<Vec<Product>>, AppError> { Ok(Json(service.get_all_products().await?)) } async fn get_product_by_id( State(service): State<AppState>, Path(id): Path<String>, ) -> Result<Json<Product>, AppError> { Ok(Json(service.get_product_by_id(&id).await?)) } async fn create_product( State(service): State<AppState>, Json(dto): Json<CreateProductDto>, ) -> Result<Json<Product>, AppError> { Ok(Json(service.create_product(dto).await?)) } async fn update_product( State(service): State<AppState>, Path(id): Path<String>, Json(dto): Json<UpdateProductDto>, ) -> Result<Json<Product>, AppError> { Ok(Json(service.update_product(&id, dto).await?)) } async fn delete_product( State(service): State<AppState>, Path(id): Path<String>, ) -> Result<StatusCode, AppError> { service.delete_product(&id).await?; Ok(StatusCode::NO_CONTENT) } async fn order_product( State(service): State<AppState>, Path(id): Path<String>, Json(payload): Json<OrderPayload>, ) -> Result<StatusCode, AppError> { service.order_product(&id, payload.quantity).await?; Ok(StatusCode::OK) } #[derive(serde::Deserialize)] struct OrderPayload { quantity: u32, } // Custom error handling for Axum struct AppError(ServiceError); impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { let (status, error_message) = match self.0 { ServiceError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), ServiceError::InvalidData(msg) => (StatusCode::BAD_REQUEST, msg), ServiceError::InsufficientStock(name, available, requested) => { (StatusCode::BAD_REQUEST, format!("Insufficient stock for {}. Available: {}, Requested: {}", name, available, requested)) }, ServiceError::DatabaseError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database operation failed".to_string()), // Handle other ServiceErrors accordingly }; (status, Json(serde_json::json!({{"error": error_message}}))).into_response() } } // Enable conversion from ServiceError to AppError impl From<ServiceError> for AppError { fn from(inner: ServiceError) -> Self { AppError(inner) } }
この構造では:
ProductService
はR: ProductRepository
のArc<R>
を受け取ります。これは依存関係注入です。リポジトリをサービスに注入しています。ProductService
のcreate_product
とupdate_product
メソッドには、明示的なビジネス検証(例:価格と在庫はゼロにできません)が含まれています。order_product
メソッドは、複雑なビジネスルールを示しています。注文を許可する前に利用可能な在庫を確認します。このロジックは完全にサービス内にあります。main.rs
のHTTPハンドラーは薄いです。リクエストを受信し、適切なサービスメソッドを呼び出し、レスポンスをフォーマットするか、エラーを処理します。ビジネス固有のロジックは含まれていません。AppError
とそのIntoResponse
実装は、サービス固有のエラーを適切なHTTPレスポンスに変換する方法を示しており、エラー処理の懸念を分離しています。
このアプローチの利点:
- 関心の分離: ビジネスロジックは、Webの懸念(HTTP処理)およびデータアクセス –––- – –– – – –- – – – """"""""" """】"""】"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""""" """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""""" """"""""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""" (r) """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" """"""""""""""""""""""""""""""""""""""""""""""" """"""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """"""""""""""""" """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" """"""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """"""""""""""""""""""""""""/"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """""""""""""""""""""""""""""""""""""""""""" """"""""""""""""""""""""""""""""""" """"""""""""""""""""""""""""""""""""""""""""