🤖

RustでDDD実装

に公開

概要

RustでDDDを実装する機会が今後増えていきそうだったので、RustでDDDを実装してみることにしました。今回は簡単にユーザーの登録と検索を実装していきます。

開発

ディレクトリ構成

まず、ディレクトリ構成は下記の通りです。各構成要素については、順番に説明していきます。

.
├── controller
│   ├── mod.rs
│   └── user_handler.rs
├── domain
│   ├── mod.rs
│   └── user.rs
├── main.rs
├── prepare.rs
├── repository
│   ├── mod.rs
│   └── user_repository.rs
├── services
│   ├── mod.rs
│   └── service_user.rs
└── usecase
    ├── mod.rs
    └── register_user.rs

各構成要素の説明

0. ドメインの設計: Domain

ここでは、ValueObject(VO)Entityを実装します。

ValueObjectは、オリジナルの便利な変数型・単位を作ってあげるようなイメージで、下記のような特徴を持っています。

  • 一意性を持たない
    • hoge_idのような識別子が存在しない
  • イミュータブルオブジェクトである
    • オブジェクトが生成されたタイミングで値が固定され変化しない
  • 副作用を持たない
    • 何かしらの操作によって状態変化してはいけない

一方で、Entityは下記の特徴を持っています。

  • ドメインモデルの中で特定の識別子(ID)によって区別されるオブジェクト
  • 集約ルートと集約内のエンティティを定義する
    • 例:Circle(集約ルート)とMember(集約内のメンバー)

今回は簡単にUserをdomainフォルダの中に実装します。

src/domain/user.rs
use serde::{Serialize, Deserialize};

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

impl User {
    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            name: name.into(),
        }
    }
}

1. 抽象的な定義(Trait実装): Repository

次に、リポジトリを実装します。
リポジトリはDB設計に依存せず、永続化の抽象が目的になります。

src/repository/user_repository.rs
use crate::domain::user::User;

#[async_trait::async_trait]
pub trait UserRepository: Send + Sync {
    async fn save(&self, user: User);
    async fn find_by_id(&self, id: &str) -> Option<User>;
}

2. 具体的なメソッドを実装: Service(infrastructure)

重要なのは、トレイトという役割に、この構造体を当てはめるという視点です。

例えば、下記のような関係性があったとします。

  • UserRepository = 保存というインターフェース(契約)
  • InMemoryUserRepository = それを担う実装者

上記の結果を組み合わせると、下記のようになります。

  • impl UserRepository for InMemoryUserRepository = 「InMemoryUserRepository に保存の責任を与える」

これをrustで表現すると、今回のユーザー登録と検索では、下記のように実装していきます。今回はDBへの保存はせずInMemoryにしてるので、postgreなどで保存などをしたい場合はservicesではなく、infrastructureフォルダとして定義して実装したほうが良いかもしれません。

src/services/service_user.rs
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;

use crate::domain::user::User;
use crate::repository::user_repository::UserRepository;

pub struct InMemoryUserRepository {
    store: Arc<RwLock<HashMap<String, User>>>,
}

impl InMemoryUserRepository { 
    pub fn new() -> Self {
        Self {
            store: Arc::new(RwLock::new(HashMap::new())),
        }
    }
}

#[async_trait::async_trait]
impl UserRepository for InMemoryUserRepository {
    async fn save(&self, user: User) {
        self.store.write().await.insert(user.id.clone(), user);
    }

    async fn find_by_id(&self, id: &str) -> Option<User> {
        self.store.read().await.get(id).cloned()
    }
}

3. アプリケーションレイヤー: Usecase

Entity や VO を使って、レポジトリー (インフラ層) に処理を依頼します。

重要なのは、リポジトリに処理を依頼しますが、インフラ層には依存してないところです。抽象に依存させるために、トレイトを使っているのが重要になります。

use crate::domain::user::User;
use crate::repository::user_repository::UserRepository;
use crate::services::service_user::InMemoryUserRepository;

pub struct RegisterUserUseCase<R: UserRepository> {
    repo: R,
}

impl<R: UserRepository> RegisterUserUseCase<R> {
    pub fn new(repo: R) -> Self {
        Self { repo }
    }

    pub async fn execute(&self, id: String, name: String) {
        let user = User::new(id, name);
        self.repo.save(user).await;
    }

    pub async fn find(&self, id: String) -> Option<User> {
        self.repo.find_by_id(&id).await
    }
}

4. プレゼンテーションレイヤー: Handler

ここでは、一番最初に載せたディレクトリ構造だとcontrollerフォルダに該当しており、エンドポイントの定義、リクエストの受け取り、レスポンス、アプリケーション層に渡す値のマッピングを行います。

src/controller/user_handler.rs
use axum::{Json, extract::State, response::IntoResponse, http::StatusCode};
use serde::Deserialize;
use std::sync::Arc;

use crate::usecase::register_user::RegisterUserUseCase;
use crate::repository::user_repository::UserRepository;

#[derive(Deserialize)]
pub struct RegisterUserInput {
    pub id: String,
    pub name: String,
}

pub async fn register_user_handler<R: UserRepository>(
    State(usecase): State<Arc<RegisterUserUseCase<R>>>,
    Json(input): Json<RegisterUserInput>,
) {
    // let usecase = RegisterUserUseCase::new();
    usecase.execute(input.id, input.name).await
}

pub async fn find_user_handler<R: UserRepository>(
    State(usecase): State<Arc<RegisterUserUseCase<R>>>,
    Json(input): Json<RegisterUserInput>,
) -> impl IntoResponse {
    match usecase.find(input.id).await {
        Some(user) => (StatusCode::OK, Json(user)).into_response(),
        None => (StatusCode::NOT_FOUND, "User not found").into_response(),
    }
}

5. main関数

最後にルーティングの定義とサーバーの立ち上げを行います。今回はaxumで実装しています。

src/main.rs
mod domain;
mod repository;
mod usecase;
mod services;
mod controller;

use axum::{Router, routing::post};
use axum_server::Server;
use std::sync::Arc;
use crate::services::service_user::InMemoryUserRepository;
use crate::usecase::register_user::RegisterUserUseCase;
use crate::controller::user_handler::{find_user_handler, register_user_handler};

#[tokio::main]
async fn main() {
    let repo = InMemoryUserRepository::new();
    let usecase = Arc::new(RegisterUserUseCase::new(repo));

    let app = Router::new()
        .route("/register_user", post(register_user_handler::<InMemoryUserRepository>))
        .route("/find_user", post(find_user_handler::<InMemoryUserRepository>))
        .with_state(usecase);

    Server::bind("0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

実行方法

こちらの構成は、下記のリポジトリに実装されています。

https://github.com/bamboo-nova/ddd-rust-sample

下記の手順を実行することで、DDDの形でユーザーの登録と検索の挙動を確認することができます。

  • cargo run を実行する
  • 別のターミナルを開いて、User情報を登録させる
curl -X POST http://localhost:3000/register_user \
  -H "Content-Type: application/json" \
  -d '{"id": "u123", "name": "Yamada Taro"}'
  • 登録したら、User情報を取得する
curl -X POST http://localhost:3000/find_user \
  -H "Content-Type: application/json" \
  -d '{"id": "u123", "name": "Yamada Taro"}'

まとめ:構造体→動作への委譲の設計パターン

DDDでは抽象(trait StoreRepository)から具象(Server)への委譲が重要で、下記の関係性があると考えられます。

  • RepositoryはDB設計に依存せず、永続化の抽象を目的としてる
  • 実際の永続化はインフラ層の責任としてinfrastructureに任せている
  • UseCaseは複数リポジトリを横断できるように設計されており、アプリケーションレイヤーとしての役割を担っている

DDDにおける依存関係は下記のようなフローチャートで表現することができます。

[Usecase<R>] ---R: StoreRepository-->
    [Server] ---impl StoreRepository for Server-->
        fn save(...)
            └─> self.reply_to_verify()

ビジネス層の人にも理解できるように説明をするなら、Domainはルール(仕様)だけが記載されており、依存は少ないのが特徴で、実際の実行制御ロジック(プロセス)はServiceやUsecaseが担っているます。

Discussion