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フォルダの中に実装します。
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設計に依存せず、永続化の抽象が目的になります。
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フォルダとして定義して実装したほうが良いかもしれません。
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フォルダに該当しており、エンドポイントの定義、リクエストの受け取り、レスポンス、アプリケーション層に渡す値のマッピングを行います。
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で実装しています。
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();
}
実行方法
こちらの構成は、下記のリポジトリに実装されています。
下記の手順を実行することで、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