Webアプリケーション開発においてレイヤードアーキテクチャは広く採用されていますが、Rustで同じ設計を適用しようとすると、所有権やライフタイムといった言語固有の制約に直面します。「Rustとレイヤードアーキテクチャは相性がよいのか」という疑問は、Rustでの本格的なアプリケーション開発を検討する多くのエンジニアが抱える課題です。

結論として、Rustとレイヤードアーキテクチャの相性は良好です。ただし、JavaやGoと同じ感覚で実装するとコンパイルエラーに阻まれます。Rust固有の仕組みを理解した上で設計すれば、型安全性とレイヤー分離の両方を高いレベルで実現できます。

レイヤードアーキテクチャの基本構造

レイヤードアーキテクチャは、アプリケーションを責務ごとに水平な層(レイヤー)に分割する設計手法です。一般的な4層構成は以下のとおりです。

責務Rustでの実装単位
プレゼンテーション層HTTPリクエスト/レスポンス処理axumのHandler・Router
アプリケーション層ユースケースの実行、トランザクション管理サービス構造体
ドメイン層ビジネスルール、エンティティ、値オブジェクト構造体 + impl
インフラストラクチャ層DB接続、外部API呼び出しトレイト実装体

各層は上位から下位への一方向の依存関係を持ち、ドメイン層は他のどの層にも依存しません。この原則により、ビジネスロジックがフレームワークやDBの実装詳細から分離されます。

Rustの言語特性がレイヤード設計と噛み合うポイント

所有権システムによるデータフローの明示化

Rustの所有権(ownership)は、レイヤー間のデータ受け渡しを明示的にします。JavaやGoではオブジェクト参照が暗黙的にコピーされますが、Rustでは値の移動(move)が基本です。

// アプリケーション層からドメイン層への値の移動
fn create_user(input: CreateUserInput) -> Result<User, DomainError> {
    // inputの所有権はこの関数に移動している
    let name = UserName::new(input.name)?;  // input.nameの所有権が移動
    let email = Email::new(input.email)?;   // input.emailの所有権が移動
    Ok(User::new(name, email))
}

レイヤー間でデータがどの方向に流れ、どこで消費されるかがコンパイル時に保証されます。これは設計上のミス(意図しないデータ共有や変更)を防ぐ効果があります。

トレイトによる依存性逆転(DIP)

レイヤードアーキテクチャでは、上位層が下位層の具体的な実装に依存しないことが重要です。Rustではトレイト(trait)を使ってこの依存性逆転の原則(Dependency Inversion Principle)を自然に実現できます。

// ドメイン層でリポジトリのインターフェースを定義
#[async_trait]
pub trait UserRepository: Send + Sync {
    async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, RepositoryError>;
    async fn save(&self, user: &User) -> Result<(), RepositoryError>;
}

// アプリケーション層はトレイトに依存(具体実装には依存しない)
pub struct UserService<R: UserRepository> {
    repository: R,
}

impl<R: UserRepository> UserService<R> {
    pub fn new(repository: R) -> Self {
        Self { repository }
    }

    pub async fn get_user(&self, id: &UserId) -> Result<Option<User>, AppError> {
        self.repository
            .find_by_id(id)
            .await
            .map_err(AppError::from)
    }
}

ジェネリクスとトレイト境界を組み合わせることで、コンパイル時に型チェックが行われ、実行時のオーバーヘッドなしにDIPを実現できます。JavaのインターフェースやGoのインターフェースと異なり、Rustのトレイトはゼロコスト抽象化として機能します。

モジュールシステムとCargo workspaceによるレイヤー分離

Rustのモジュールシステムはレイヤーの分離を物理的に強制できます。特にCargo workspaceを使ったマルチクレート構成が有効です。

my-app/
├── Cargo.toml          # workspace定義
├── domain/
│   ├── Cargo.toml      # 依存: なし(外部crateも最小限)
│   └── src/lib.rs
├── application/
│   ├── Cargo.toml      # 依存: domain
│   └── src/lib.rs
├── infrastructure/
│   ├── Cargo.toml      # 依存: domain, application
│   └── src/lib.rs
└── presentation/
    ├── Cargo.toml      # 依存: application, infrastructure
    └── src/main.rs

ワークスペースのルート Cargo.toml は以下のように記述します。

[workspace]
members = ["domain", "application", "infrastructure", "presentation"]
resolver = "2"

各クレートの Cargo.toml で依存関係を明示することで、層の依存方向がビルドシステムレベルで強制されます。ドメインクレートがインフラストラクチャクレートに依存しようとすると、そもそも Cargo.toml に書かれていないためコンパイルエラーになります。

この仕組みはJavaのマルチモジュール(Gradle/Maven)やGoのパッケージ構成よりも厳格です。ビルドツールが依存方向の違反を即座に検出するため、アーキテクチャの腐敗を物理的に防止できます。

Rustでレイヤードアーキテクチャを採用する際の課題と対処法

課題1: 所有権とライフタイムの壁

レイヤー間でデータを受け渡す際、参照(&T)を使うとライフタイムの制約が伝播し、コードが複雑になることがあります。

対処法: 各層でDTOを分離し、値の移動(move)を基本にする

// プレゼンテーション層のリクエスト型
#[derive(Deserialize)]
pub struct CreateUserRequest {
    pub name: String,
    pub email: String,
}

// アプリケーション層のコマンド型
pub struct CreateUserCommand {
    pub name: String,
    pub email: String,
}

// 変換はFromトレイトで実装
impl From<CreateUserRequest> for CreateUserCommand {
    fn from(req: CreateUserRequest) -> Self {
        Self {
            name: req.name,
            email: req.email,
        }
    }
}

各層に専用の型を用意し、From / Into トレイトで変換するパターンが定番です。ライフタイム注釈は不要になり、レイヤー間の結合度も下がります。Clone を回避し move を基本とすることで、パフォーマンスへの影響も最小限に抑えられます。

課題2: async traitの取り扱い

リポジトリのメソッドは非同期(async)にしたいケースが多いですが、Rustのトレイトで非同期メソッドを扱うには工夫が必要です。

Rust 1.75以降ではRPIT(Return Position Impl Trait)in traitが安定化されましたが、dyn Traitでの使用にはまだ制約があります。実用上は async-trait クレートの利用が一般的です。

use async_trait::async_trait;

#[async_trait]
pub trait OrderRepository: Send + Sync {
    async fn find_by_id(&self, id: &OrderId) -> Result<Option<Order>, RepositoryError>;
    async fn save(&self, order: &Order) -> Result<(), RepositoryError>;
    async fn delete(&self, id: &OrderId) -> Result<(), RepositoryError>;
}

async-trait は内部的に Pin<Box<dyn Future>> に変換するため、ヒープアロケーションが発生します。ただし、DB I/Oやネットワーク通信の待ち時間と比較すれば、このオーバーヘッドは実測上ほぼ無視できます。

課題3: エラー型の設計

レイヤードアーキテクチャでは各層のエラーをどう統合するかが設計課題です。Rustでは thiserroranyhow の使い分けが重要になります。

// ドメイン層: 具体的なエラー型を定義
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
    #[error("ユーザー名は1〜50文字で入力してください")]
    InvalidUserName,
    #[error("メールアドレスの形式が不正です")]
    InvalidEmail,
}

// アプリケーション層: ドメインエラーをラップ
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error(transparent)]
    Domain(#[from] DomainError),
    #[error(transparent)]
    Repository(#[from] RepositoryError),
    #[error("ユーザーが見つかりません: {0}")]
    UserNotFound(String),
}

// プレゼンテーション層: HTTPステータスコードへ変換
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::Domain(_) => (StatusCode::BAD_REQUEST, self.to_string()),
            AppError::UserNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::Repository(_) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "内部エラーが発生しました".to_string(),
            ),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

設計指針: ドメイン層とアプリケーション層では thiserror で明示的なエラー型を定義し、インフラストラクチャ層の内部実装でのみ anyhow を使う方法が実用的です。これにより、上位層ではパターンマッチで適切にエラーを分岐できます。

課題4: DI(依存性注入)の組み立て

Javaの Spring やGoの wire のようなDIコンテナはRustには標準的なものがありません。Rustではコンストラクタインジェクション(手動DI)が主流です。

// main.rsでの組み立て例
#[tokio::main]
async fn main() {
    let pool = PgPool::connect("postgres://localhost/mydb")
        .await
        .expect("DB接続に失敗");

    // インフラ層の具体実装を生成
    let user_repo = PgUserRepository::new(pool.clone());
    let order_repo = PgOrderRepository::new(pool.clone());

    // アプリケーション層にインフラ層の実装を注入
    let user_service = UserService::new(user_repo);
    let order_service = OrderService::new(order_repo);

    // プレゼンテーション層(axumのRouter)にサービスを登録
    let app = Router::new()
        .route("/users/:id", get(get_user))
        .route("/users", post(create_user))
        .with_state(AppState {
            user_service: Arc::new(user_service),
            order_service: Arc::new(order_service),
        });

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

DIコンテナがない分、依存関係がすべてコンパイル時に解決されます。アプリケーションの起動時に「何が何に依存しているか」が main.rs を読むだけで把握できるのは、大規模プロジェクトでの保守性において利点です。

他言語との比較: レイヤードアーキテクチャの実装しやすさ

観点RustJava (Spring)GoTypeScript (NestJS)
依存性逆転trait + ジェネリクス(ゼロコスト)interface + DI Containerinterface(暗黙的)abstract class / interface + DI Container
レイヤー分離の強制Cargo workspace(ビルドレベル)マルチモジュール(Gradle/Maven)パッケージ構成(規約ベース)モジュールシステム(規約ベース)
DI方式手動コンストラクタ注入アノテーション自動注入手動 or wireデコレータ自動注入
エラーハンドリングResult型(コンパイル時強制)例外(実行時)error値(慣習ベース)例外(実行時)
テスト時のモックmockallクレート等Mockito等手動実装 or gomockjest.mock等
学習コスト高(所有権・ライフタイム)中(DI設定の理解)中(デコレータの理解)
型安全性非常に高い高い中程度高い(strict mode時)

Rustはレイヤードアーキテクチャの実装において「学習コストが高い代わりに、型安全性とパフォーマンスの両方で最も高い水準を実現できる」というポジションにあります。

axum + Cargo workspaceでの実装例

実際にaxumフレームワークとCargo workspaceを使って、レイヤードアーキテクチャのプロジェクトを構成する手順を示します。

ステップ1: ワークスペースの作成

mkdir my-layered-app && cd my-layered-app
cargo init --name presentation
cargo init --lib --name domain domain
cargo init --lib --name application application
cargo init --lib --name infrastructure infrastructure

ステップ2: ドメイン層の実装

ドメイン層は外部クレートへの依存を最小限にします。

// domain/src/lib.rs
pub mod model;
pub mod repository;
pub mod error;
// domain/src/model/user.rs
#[derive(Debug, Clone)]
pub struct User {
    id: UserId,
    name: UserName,
    email: Email,
}

#[derive(Debug, Clone, PartialEq)]
pub struct UserId(String);

#[derive(Debug, Clone)]
pub struct UserName(String);

impl UserName {
    pub fn new(value: String) -> Result<Self, DomainError> {
        if value.is_empty() || value.len() > 50 {
            return Err(DomainError::InvalidUserName);
        }
        Ok(Self(value))
    }

    pub fn value(&self) -> &str {
        &self.0
    }
}

値オブジェクト(UserId, UserName)をnewtypeパターンで定義し、生成時にバリデーションを行います。プリミティブ型(String)を直接使わないことで、層をまたいだ不正な値の流入を型レベルで防止できます。

ステップ3: アプリケーション層の実装

// application/src/usecase/create_user.rs
use domain::model::user::{User, UserName, Email};
use domain::repository::UserRepository;
use domain::error::DomainError;

pub struct CreateUserUseCase<R: UserRepository> {
    user_repository: R,
}

impl<R: UserRepository> CreateUserUseCase<R> {
    pub fn new(user_repository: R) -> Self {
        Self { user_repository }
    }

    pub async fn execute(&self, input: CreateUserInput) -> Result<User, AppError> {
        let name = UserName::new(input.name)?;
        let email = Email::new(input.email)?;
        let user = User::new(name, email);
        self.user_repository.save(&user).await?;
        Ok(user)
    }
}

pub struct CreateUserInput {
    pub name: String,
    pub email: String,
}

ステップ4: テスト戦略

レイヤードアーキテクチャの最大の利点の一つが、レイヤーごとの独立したテストです。Rustでは mockall クレートを使ってリポジトリのモックを生成できます。

#[cfg(test)]
mod tests {
    use super::*;
    use domain::repository::MockUserRepository;

    #[tokio::test]
    async fn ユーザーを正常に作成できる() {
        let mut mock_repo = MockUserRepository::new();
        mock_repo
            .expect_save()
            .times(1)
            .returning(|_| Ok(()));

        let usecase = CreateUserUseCase::new(mock_repo);
        let input = CreateUserInput {
            name: "田中太郎".to_string(),
            email: "tanaka@example.com".to_string(),
        };

        let result = usecase.execute(input).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn 空のユーザー名はエラーになる() {
        let mock_repo = MockUserRepository::new();
        let usecase = CreateUserUseCase::new(mock_repo);
        let input = CreateUserInput {
            name: "".to_string(),
            email: "test@example.com".to_string(),
        };

        let result = usecase.execute(input).await;
        assert!(matches!(
            result,
            Err(AppError::Domain(DomainError::InvalidUserName))
        ));
    }
}

ドメイン層の単体テストにはモックすら不要です。純粋な関数とデータ構造のテストだけで済むため、テストの実行速度が非常に速くなります。

レイヤードアーキテクチャとオニオン/クリーンアーキテクチャの使い分け

Rustプロジェクトで「レイヤードアーキテクチャ」と混同されやすいのが、オニオンアーキテクチャやクリーンアーキテクチャです。

設計手法依存方向Rustとの相性適用規模の目安
レイヤードアーキテクチャ上→下(一方向)高い。Cargo workspaceで自然に表現可能小〜中規模
オニオンアーキテクチャ外→内(同心円)高い。トレイトでドメイン中心の設計が実現しやすい中〜大規模
クリーンアーキテクチャ外→内 + ユースケース中心高いが層数が増え、型変換のボイラープレートも増加大規模

実際のRustプロジェクトでは、レイヤードアーキテクチャをベースに依存性逆転を取り入れた「オニオン寄りのレイヤード」が採用されるケースが多くあります。ドメイン層にリポジトリのトレイトを定義し、インフラ層で実装するパターンは、純粋なレイヤードというよりもオニオンアーキテクチャの要素を含んでいます。

小規模なプロジェクトではレイヤードの単純さが利点になり、規模が大きくなるにつれてクリーンアーキテクチャへ段階的に移行する戦略が現実的です。

実プロジェクトで役立つクレート構成

レイヤードアーキテクチャのRustプロジェクトでよく使われるクレートを層別にまとめます。

プレゼンテーション層

クレート用途
axumWebフレームワーク(tokioベース、型安全なルーティング)
towerミドルウェアレイヤー(ロギング、認証、レート制限)
serde / serde_jsonリクエスト・レスポンスのシリアライズ
utoipaOpenAPI仕様の自動生成

アプリケーション層

クレート用途
async-traitトレイトでのasyncメソッド対応
thiserrorエラー型の定義
tracing構造化ロギング

ドメイン層

クレート用途
(原則外部依存なし)ビジネスロジックの純粋性を保つ
derive_more(任意)newtypeのDisplay等の自動実装

インフラストラクチャ層

クレート用途
sqlx非同期SQLクエリ(コンパイル時チェック対応)
sea-ormエンティティベースの非同期ORM
reqwest外部HTTPクライアント
redisRedisクライアント

sqlxの query! マクロはコンパイル時にSQLの型チェックを行うため、インフラ層のバグをビルド段階で発見できます。これはRust + レイヤードアーキテクチャならではの安全性です。

まとめ

Rustとレイヤードアーキテクチャの相性は良好です。所有権システムがレイヤー間のデータフローを明示化し、トレイトがゼロコストで依存性逆転を実現し、Cargo workspaceがレイヤーの分離をビルドレベルで強制します。

一方で、以下の点はRust固有の考慮事項として押さえておく必要があります。

  • 各層にDTO型を用意し、From / Into での変換を基本とする
  • 非同期トレイトには async-trait クレートを活用する
  • エラー型は層ごとに thiserror で定義し、From で変換チェーンを構築する
  • DIは手動コンストラクタ注入とし、main.rs で依存グラフを組み立てる

レイヤードアーキテクチャはJavaやGoの経験があるエンジニアにとって馴染みやすい設計パターンです。Rustに入門する際のアーキテクチャとして採用し、プロジェクトの成長に合わせてオニオンやクリーンアーキテクチャへ発展させていくアプローチが効果的です。