堅牢なRust Webプロジェクトのためのモジュラーデザイン
Min-jun Kim
Dev Intern · Leapcell

はじめに
大規模なWebアプリケーションの構築は、特有の課題をもたらします。プロジェクトが複雑化するにつれて、コードベースはすぐに管理不能になり、開発速度の低下、バグ数の増加、そしてフラストレーションのたまる開発者体験につながる可能性があります。これは、慎重なアーキテクチャの決定が、コンパイル時間と実行時パフォーマンスの両方に大きな影響を与える可能性のある、パフォーマンスが重要な言語であるRustでは特に当てはまります。Actix WebやAxumのようなRustのWebフレームワークにとって、よく考え抜かれたモジュラーデザインを採用することは、単なるベストプラクティスではなく)、保守性、スケーラビリティ、そして共同開発を促進するための必要不可欠なものです。この記事では、Actix WebとAxumの大規模プロジェクトを効果的に構造化する方法を掘り下げ、アプリケーションが進化しても堅牢で適応性の高いままであることを保証するために、モジュラー性の原則と実践的な実装についてガイドします。
Rust Webプロジェクトにおけるモジュラリティの理解
具体的な例に入る前に、RustのWeb開発におけるモジュラリティを取り巻くいくつかのコアコンセプトを明確にしましょう。
モジュラリティ: その核心において、モジュラリティは、システムを、モジュールと呼ばれる、より小さく、独立し、交換可能なコンポーネントに分割する実践です。各モジュールは、特定の機能の断片をカプセル化し、その内部実装の詳細を隠しながら、明確に定義されたインターフェースを公開する必要があります。
クレート: Rustでは、クレートはコンパイルの基本的な単位であり、バージョン管理と配布の単位でもあります。プロジェクトは、単一のバイナリクレートまたはライブラリクレートで構成されるか、または複数の相互依存クレートで構成される「ワークスペース」である可能性があります。
モジュール(ファイルシステム): クレート内では、コードはmod
キーワードとファイルシステムの階層を使用してモジュールに編成されます。これらのモジュールは、コードを論理的に整理し、可視性を制御するのに役立ちます。
ドメイン駆動設計(DDD): ソフトウェア開発のアプローチであり、ソフトウェアの「ドメイン」または主題領域の理解とモデリングを強調します。主な概念には以下が含まれます。
- ドメイン: ユーザーがプログラムを適用する主題領域。
- 境界づけられたコンテキスト: 特定のドメインモデルが定義され、適用される論理的な境界。大規模システムの異なる部分を分離することで、複雑さを管理するのに役立ちます。
- エンティティ: 属性だけでなく、そのアイデンティティによって定義されるオブジェクト(例:「ユーザー」に一意のIDがある)。
- 値オブジェクト: その属性のみによって定義されるオブジェクト(例:「Money」オブジェクトに金額と通貨がある)。
- 集約: データ変更のために単一のユニットとして扱われる関連オブジェクトのクラスター。集約は1つのルートエンティティを持ちます。
- リポジトリ: 集約の取得と格納のための抽象化。
- サービス: エンティティまたは値オブジェクト内で自然に発生しない操作であり、しばしば複数の集約にまたがってオーケストレーションされます。
レイヤードアーキテクチャ: アプリケーションを、それぞれに特定の責任を持つ、明確に区切られた概念的なレイヤーに分割する一般的なアーキテクチャパターン。典型的なWebアプリケーションには以下が含まれる場合があります。
- プレゼンテーション/API レイヤー: HTTPリクエスト、認証、データシリアライゼーション/デシリアライゼーション(例:Actix Web/Axum ハンドラ)を処理します。
- アプリケーション/サービス レイヤー: ビジネスロジックをオーケストレーションし、ドメインサービスを呼び出し、リポジトリを操作します。
- ドメイン レイヤー: コアビジネスロジック、エンティティ、値オブジェクト、ドメインサービスを含みます。このレイヤーはフレームワークに依存しない必要があります。
- インフラストラクチャ レイヤー: データベース、ファイルシステム、外部API、メッセージキューなどの外部の懸念事項を扱います。
これらの概念を活用することで、高度に疎結合で保守性の高いWebサービスを設計できます。
原則と実装
モジュラーデザインの背後にある中心的な考え方は、高い凝集度(モジュール内の要素は互いに関連している)と低い結合度(モジュールは独立しており、互いに最小限の依存関係しか持たない)を達成することです。
1. ワークスペースを使用したプロジェクト構造
大規模プロジェクトの場合、Rustのワークスペース機能は非常に価値があります。これにより、複数の関連クレートをまとめて管理できます。これは、さまざまな論理コンポーネントを独自のクレートに分離するための一般的な戦略です。
マルチサービスアプリケーションや、明確な論理境界を持つアプリケーションを検討してください。
my_big_project/
├── Cargo.toml # ワークスペースのCargo.toml
├── services/
│ ├── user-service/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # ユーザーのためのActix Web/Axumアプリ
│ ├── product-service/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # 製品のためのActix Web/Axumアプリ
│ └── order-service/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # 注文のためのActix Web/Axumアプリ
└── shared_crates/
├── domain/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # コアビジネスロジック、エンティティ、値オブジェクト
├── infrastructure/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # データベースアクセス(例:SQLx)、外部APIクライアント
└── common_types/
├── Cargo.toml
└── src/
└── lib.rs # 共通のDTO、エラータイプ
my_big_project/Cargo.toml
:
[workspace] members = [ "services/user-service", "services/product-service", "services/order-service", "shared_crates/domain", "shared_crates/infrastructure", "shared_crates/common_types", ] [workspace.dependencies] # 共通の依存関係をここで定義し、一貫したバージョンを確保します。 # 例:Axumサービスの場合: tokio = { version = "1.36", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } # ... その他の共通の依存関係
各services/*
クレートは、個別のCargo.toml
ファイルを通じて関連するshared_crates/*
に依存します。たとえば、user-service/Cargo.toml
には次のようなものが含まれる場合があります。
[dependencies] axum = "0.7.4" tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } domain = { path = "../../shared_crates/domain" } infrastructure = { path = "../../shared_crates/infrastructure" } common_types = { path = "../../shared_crates/common_types" } # ... その他のサービス固有の依存関係
この構造は、関心を明確に分離します。
- 各
service
クレートは、独立してデプロイ可能なユニットです。 domain
は、Webフレームワークやデータベースに依存しない、コアで普遍的なビジネスロジックを保持します。infrastructure
は、外部の依存関係をカプセル化します。common_types
は、共通のデータ構造の重複を防ぎます。
2. クレート内のレイヤードアーキテクチャ
単一のバイナリクレート内(まだマイクロサービスに分割されていないシンプルなWebサービスなど)でも、Rustのモジュールシステムを使用したレイヤードアーキテクチャは非常に重要です。
user-service
を例にとり、レイヤードアーキテクチャの原則を適用してみましょう。
user-service/
└── src/
├── main.rs # エンドポイント、サーバー、状態の初期化
├── api/ # プレゼンテーション/API レイヤー
│ ├── mod.rs # ルート、状態抽出を定義
│ ├── handlers/ # HTTP リクエストハンドラ
│ │ ├── mod.rs
│ │ └── user_handler.rs
│ └── dtos/ # API 入出力のためのデータ転送オブジェクト
│ └── mod.rs
├── application/ # アプリケーション/サービス レイヤー
│ ├── mod.rs
│ ├── services/ # ドメインロジックをオーケストレーション、リポジトリとやり取り
│ │ ├── mod.rs
│ │ └── user_app_service.rs
│ └── commands/ # アプリケーションサービスへの入力
│ └── mod.rs
├── domain/ # ドメイン レイヤー
│ ├── mod.rs
│ ├── entities/ # User エンティティなど
│ │ └── mod.rs
│ ├── value_objects/ # UserId, Email, Passwordなど
│ │ └── mod.rs
│ ├── services/ # 純粋なドメインロジック(例:パスワードハッシュ)
│ │ └── mod.rs
│ └── repositories/ # リポジトリインターフェースを定義するトレイト
│ └── mod.rs
└── infrastructure/ # インフラストラクチャ レイヤー
├── mod.rs
├── persistence/ # リポジトリトレイトのデータベース実装
│ ├── mod.rs
│ └── user_repository_impl.rs
├── config.rs # アプリケーション設定の読み込み
└── error.rs # カスタムエラー処理
コードスニペット例(Axum):
domain/repositories/user_repository.rs
(リポジトリのトレイト定義):
use async_trait::async_trait; use crate::domain::entities::User; use crate::domain::value_objects::UserId; use crate::infrastructure::error::ServiceError; #[async_trait] pub trait UserRepository { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, ServiceError>; async fn save(&self, user: &mut User) -> Result<(), ServiceError>; // ... その他のユーザー関連データベース操作 }
infrastructure/persistence/user_repository_impl.rs
(具体的なデータベース実装):
use async_trait::async_trait; use sqlx::{PgPool, FromRow}; use crate::domain::entities::User; use crate::domain::value_objects::{UserId, Email}; use crate::domain::repositories::UserRepository; use crate::infrastructure::error::ServiceError; // データベーススキーマのUserを表す構造体 #[derive(Debug, Clone, FromRow)] struct UserDb { id: String, email: String, username: String, // ... その他のフィールド } pub struct PgUserRepository { pool: PgPool, } impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } // DBモデルからドメインエンティティへの変換 impl TryFrom<UserDb> for User { type Error = ServiceError; // または、より具体的なドメインエラー fn try_from(db_user: UserDb) -> Result<Self, Self::Error> { Ok(User::new( UserId::new(&db_user.id).map_err(|e| ServiceError::InternalServerError(e.to_string()))?, Email::new(&db_user.email).map_err(|e| ServiceError::InternalServerError(e.to_string()))?, db_user.username, // ... )) } } // ドメインエンティティからDBモデルへの変換 impl From<&User> for UserDb { fn from(user: &User) -> Self { UserDb { id: user.id().to_string(), email: user.email().to_string(), username: user.username().to_string(), // ... } } } #[async_trait] impl UserRepository for PgUserRepository { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, ServiceError> { let user_db = sqlx::query_as!(UserDb, "SELECT id, email, username FROM users WHERE id = $1", id.to_string()) .fetch_optional(&self.pool) .await .map_err(|e| ServiceError::DatabaseError(e.to_string()))?; user_db.map(|u| u.try_into()).transpose() } async fn save(&self, user: &mut User) -> Result<(), ServiceError> { let user_db: UserDb = user.into(); sqlx::query( "INSERT INTO users (id, email, username) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, username = $3", user_db.id, user_db.email, user_db.username ) .execute(&self.pool) .await .map_err(|e| ServiceError::DatabaseError(e.to_string()))?; Ok(()) } }
application/services/user_app_service.rs
(アプリケーションサービス):
use std::sync::Arc; use crate::domain::entities::User; use crate::domain::value_objects::{UserId, Email}; use crate::domain::repositories::UserRepository; use crate::api::dtos::{CreateUserRequest, UserResponse}; use crate::infrastructure::error::ServiceError; pub struct UserApplicationService<R: UserRepository> { user_repository: Arc<R>, } impl<R: UserRepository> UserApplicationService<R> { pub fn new(user_repository: Arc<R>) -> Self { Self { user_repository } } pub async fn create_user(&self, request: CreateUserRequest) -> Result<UserResponse, ServiceError> { let email = Email::new(&request.email) .map_err(|e| ServiceError::BadRequest(e.to_string()))?; // 例:ユーザーがすでに存在するかどうかを確認 // if self.user_repository.find_by_email(&email).await?.is_some() { // return Err(ServiceError::Conflict("User with this email already exists".to_string())); // } let mut user = User::new_with_generated_id(email, request.username); self.user_repository.save(&mut user).await?; Ok(UserResponse { id: user.id().to_string(), email: user.email().to_string(), username: user.username().to_string(), }) } pub async fn get_user_by_id(&self, id: &str) -> Result<Option<UserResponse>, ServiceError> { let user_id = UserId::new(id) .map_err(|e| ServiceError::BadRequest(e.to_string()))?; let user = self.user_repository.find_by_id(&user_id).await?; Ok(user.map(|u| UserResponse { id: u.id().to_string(), email: u.email().to_string(), username: u.username().to_string(), })) } }
api/handlers/user_handler.rs
(Axum ハンドラ):
use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use std::sync::Arc; use crate::{ api::dtos::{CreateUserRequest, UserResponse}, application::services::user_app_service::UserApplicationService, domain::repositories::UserRepository, infrastructure::error::ServiceError, }; // 共有依存関係を保持するAppStateを定義 #[derive(Clone)] pub struct AppState<R: UserRepository> { pub user_app_service: Arc<UserApplicationService<R>>, } pub async fn create_user_handler<R: UserRepository>( State(app_state): State<AppState<R>>, Json(request): Json<CreateUserRequest>, ) -> Result<Json<UserResponse>, ServiceError> { let response = app_state.user_app_service.create_user(request).await?; Ok(Json(response)) } pub async fn get_user_handler<R: UserRepository>( State(app_state): State<AppState<R>>, Path(user_id): Path<String>, ) -> Result<(StatusCode, Json<UserResponse>), ServiceError> { if let Some(user_response) = app_state.user_app_service.get_user_by_id(&user_id).await? { Ok((StatusCode::OK, Json(user_response))) } else { Err(ServiceError::NotFound("User not found".to_string())) } }
main.rs
(アプリケーションを組み立てる):
use axum::{routing::{get, post}, Router}; use std::{net::SocketAddr, sync::Arc}; use sqlx::PgPool; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod api; mod application; mod domain; mod infrastructure; use infrastructure::{ config::Config, error::ServiceError, persistence::user_repository_impl::PgUserRepository, }; use api::handlers::{ user_handler::{self, AppState}, }; use application::services::user_app_service::UserApplicationService; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "user_service=debug,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); let config = Config::load_from_env(); let pool = PgPool::connect(&config.database_url).await?; // データベースマイグレーションを実行 sqlx::migrate!("./migrations") .run(&pool) .await .map_err(|e| ServiceError::DatabaseError(format!("Migration failed: {}", e)))?; // リポジトリとサービスをインスタンス化 let user_repo = Arc::new(PgUserRepository::new(pool.clone())); let user_app_service = Arc::new(UserApplicationService::new(user_repo.clone())); let app_state = AppState { user_app_service, }; let app = Router::new() .route("/users", post(user_handler::create_user_handler::<PgUserRepository>)) .route("/users/:user_id", get(user_handler::get_user_handler::<PgUserRepository>)) .with_state(app_state.clone()); // 状態をルーターに渡す let addr = SocketAddr::from(([127, 0, 0, 1], config.port)); tracing::info!("listening on {}", addr); axum::Server::bind(&addr) .serve(app.into_make_service()) .await?; Ok(()) }
この構造は、責任を明確に区別します。
domain
レイヤーは純粋なRustであり、ビジネスルールとデータモデルに焦点を当て、Axum/Actix Webやsqlx
とは完全に独立しています。infrastructure
レイヤーは、特定のテクノロジー(例:sqlx::PgPool
)を処理します。application
レイヤーは、domain
とinfrastructure
コンポーネントを調整してユースケースを実行します。api
レイヤーは、HTTPリクエストとレスポンスを処理し、汎用Webフォーマットとアプリケーション固有の入出力の間で変換します。main.rs
は、コンポジションと起動を担当します。
3. 依存関係注入とトレイトによる疎結合
UserApplicationService
とuser_handler
がR: UserRepository
に対してジェネリックであることに注目してください。これは、Rustにおける依存関係注入の強力なパターンです。UserApplicationService
内にPgUserRepository
を直接インスタンス化する代わりに、UserRepository
トレイトを実装する任意の型への依存関係を表現します。
これにより、いくつかの利点が得られます。
- テスト容易性:
UserApplicationService
の単体テストでは、実際のデータベース呼び出しをバイパスして、モックまたはフェイクのUserRepository
実装を提供できます。 - 柔軟性:
UserRepository
実装を新規作成することで、データベース実装(例:PostgreSQLからMongoDBへ)を簡単に切り替えることができます。これにより、UserApplicationService
のロジックは変更されません。 - 疎結合: レイヤーは、具体的な実装ではなく、トレイト(抽象化)にのみ依存するため、疎結合が促進されます。
4. エラーハンドリング戦略
大規模アプリケーションでは、集中化されたエラーハンドリング戦略が不可欠です。infrastructure/error.rs
にカスタムServiceError
enumを定義し、アプリケーションが遭遇する可能性のあるさまざまな種類のエラー(例:DatabaseError
、ValidationError
、NotFound
、Unauthorized
)をカプセル化します。一般的なエラータイプ(sqlx::Error
や検証エラーなど)からServiceError
へのFrom
変換を実装します。
Axumの場合、ServiceError
enumに対してIntoResponse
トレイトを実装することで、エラーを適切なHTTPレスポンスに自動的に変換できます。
// infrastructure/error.rs use axum::response::{IntoResponse, Response}; use axum::http::StatusCode; #[derive(Debug)] pub enum ServiceError { NotFound(String), BadRequest(String), Unauthorized(String), Conflict(String), DatabaseError(String), InternalServerError(String), // ... より具体的なエラーの可能性あり } impl IntoResponse for ServiceError { fn into_response(self) -> Response { let (status, error_message) = match self { ServiceError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), ServiceError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), ServiceError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), ServiceError::Conflict(msg) => (StatusCode::CONFLICT, msg), ServiceError::DatabaseError(msg) => { tracing::error!("Database error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) }, ServiceError::InternalServerError(msg) => { tracing::error!("Internal server error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string()) }, }; // クライアントのために構造化されたエラーペイロードが必要になる場合があります let body = serde_json::json!({ "error": error_message, }); (status, axum::Json(body)).into_response() } } // 便利のためにFrom変換を実装 impl From<sqlx::Error> for ServiceError { fn from(err: sqlx::Error) -> Self { ServiceError::DatabaseError(err.to_string()) } } // ... その他の一般的なエラータイプに対する`From`実装
結論
効果的なモジュラーデザインは、特にActix WebやAxumのようなフレームワークを使用する場合、スケーラブルで保守性が高く、共同開発に適したRust Webアプリケーションを構築するための基盤です。Rustのワークスペースとモジュールシステムを注意深く適用し、レイヤードアーキテクチャに従い、トレイトによる依存関係逆転を活用することで、開発者は、それぞれが明確で孤立した責任を所有する堅牢なシステムを作成できます。この意図的な整理は、コードの明瞭性、テスト容易性、そして進化する要件に対応する能力を大幅に向上させ、プロジェクトが規模と複雑さを増すにつれて成功することを保証します。モジュラーデザインは、大規模Rust Webプロジェクトをソフトウェア開発の複雑な景観をナビゲートするコンパスです。