PR

RustのaxumでDDD(ドメイン駆動設計)構成のバックエンドAPIを開発する方法まとめ

応用

こんにちは。Tomoyuki(@tomoyuki65)です。

Rustについては当ブログなどもきっかけになったりして、これから流行っていく可能性があるプログラミング言語ですが、これまではクリーンアーキテクチャを参考にしてRustのAPIの作り方を解説してきました。

ただもしこれから流行っていった場合、実務においてはDDD(ドメイン駆動設計)と呼ばれる方法で作られることが多くなると予想されます。

そんなDDDというのは、ソフトウェアが扱う対象の「ビジネス領域」や「問題領域」そのものをドメインとして定義し、その分野の専門家と開発者が協力してビジネスルールを設計に落とし込む手法です。

例えばオンラインショッピングサイトなら「商品管理」や「注文処理」、会計ソフトなら「経理」や「請求」がドメインにあたります。

このようにビジネスの中心的な課題に焦点を当て、専門家と開発者が密に連携することで、認識のズレを防ぎながらビジネスの成長に合わせてソフトウェアを柔軟に進化させていくことが可能になります。

ということで実務を想定してDDDについても試してみたので、この記事ではRustのaxumでDDD構成のバックエンドAPIを開発する方法についてまとめます。

 

RustのaxumでDDD(ドメイン駆動設計)構成のバックエンドAPIを開発する方法まとめ

まずは以下のコマンド実行して各種ファイルを作成します。

$ mkdir rust-axum-domain && cd rust-axum-domain
$ mkdir -p docker/local/rust && touch docker/local/rust/Dockerfile
$ touch compose.yml .env

※開発環境の構築にはDockerを使うため、もしまだ使えないという方はDocker Desktopなどをインストールして事前に使えるようにして下さい。

 

次に作成した各種ファイルについて、それぞれ以下のように記述します。

・「docker/local/rust/Dockerfile」

FROM rust:1.87

WORKDIR /app

COPY . .

# ホットリロード用のライブラリをインストール
RUN cargo install cargo-watch

# Rust用のリンターをインストール
RUN rustup component add clippy

# Rust用のフォーマッターをインストール
RUN rustup component add rustfmt

※Rustのバージョンについては、以前の記事と同様に2025年5月時点で最新の「1.87」を使います。また後で使う各種ライブラリも記述しています。

 

・「.env」
ENV=local
PORT=8080
RUST_LOG=info

 

・「compose.yml」

services:
  api:
    container_name: rust-axum-domain
    build:
      context: .
      dockerfile: ./docker/local/rust/Dockerfile
    command: cargo watch -x run
    volumes:
      - .:/app
    ports:
      - "8080:8080"
    # .env.testing利用時に上書きしたい環境変数を設定する
    environment:
      - ENV
      - PORT
      - RUST_LOG
    tty: true
    stdin_open: true

※環境変数はデフォルトで「.env」ファイルを読み込みますが、テスト用の「.env.testing」を使う場合は「environment」の定義も必要です。

 

次に以下のコマンドを実行し、DockerコンテナのビルドおよびRustのプロジェクトの初期化を行います。

$ docker compose build --no-cache
$ docker compose run --rm api cargo init --name rust_axum_domain

 

コマンド実行後、下図のようにRustの各種ファイルが作成されればOKです。

 

合わせて作成されたファイル「.gitignore」に「.env」を追加しておきます。

 

スポンサーリンク

共通設定のファイルを追加

次に以下のコマンドを実行し、必要になる各種クレート(ライブラリ)を追加します。

$ docker compose run --rm api cargo add axum
$ docker compose run --rm api cargo add tokio --features full
$ docker compose run --rm api cargo add serde --features derive
$ docker compose run --rm api cargo add envy
$ docker compose run --rm api cargo add thiserror
$ docker compose run --rm api cargo add chrono --features serde
$ docker compose run --rm api cargo add env_logger
$ docker compose run --rm api cargo add log
$ docker compose run --rm api cargo add tower-http --features trace --features cors
$ docker compose run --rm api cargo add tracing
$ docker compose run --rm api cargo add uuid --features v4
$ docker compose run --rm api cargo add serde_json
$ docker compose run --rm api cargo add async-trait
$ docker compose run --rm api cargo add mockall

 

次に以下のコマンドを実行し、共通設定のファイルを追加します。

$ mkdir -p src/config && touch src/config/config_settings.rs src/config/mod.rs
$ mkdir -p src/application/usecase/context && touch src/application/usecase/context/context_request.rs src/application/usecase/context/mod.rs
$ mkdir -p src/application/usecase/logger && touch src/application/usecase/logger/logger_trait.rs src/application/usecase/logger/mod.rs
$ touch src/application/usecase/mod.rs src/application/mod.rs
$ mkdir -p src/infrastructure/logger && touch src/infrastructure/logger/logger_log.rs src/infrastructure/logger/mod.rs
$ mkdir -p src/infrastructure/database && touch src/infrastructure/database/database_dummy.rs src/infrastructure/database/mod.rs
$ touch src/infrastructure/mod.rs
$ mkdir -p src/presentation/middleware && touch src/presentation/middleware/common_middleware.rs src/presentation/middleware/mod.rs
$ touch src/presentation/mod.rs
$ mkdir -p src/domain/error && touch src/domain/error/error_common.rs src/domain/error/mod.rs
$ touch src/domain/mod.rs

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/config/config_settings.rs」

use envy;
use serde::Deserialize;

// 環境変数のデフォルト値を返す関数
fn default_env() -> String {
    "local".to_string()
}

fn default_port() -> u16 {
    8080
}

fn default_rust_log() -> String {
    "info".to_string()
}

// 環境変数の構造体
#[derive(Deserialize, Debug)]
pub struct Config {
    #[serde(default = "default_env")]
    pub env: String,
    #[serde(default = "default_port")]
    pub port: u16,
    #[allow(dead_code)]
    #[serde(default = "default_rust_log")]
    pub rust_log: String,
}

// 環境変数を返す関数
pub fn get_config() -> Config {
    match envy::from_env::<Config>() {
        Ok(config) => config,
        Err(err) => {
            println!("環境変数の初期化エラー: {}", err);

            // 環境変数にデフォルト値を設定して返す
            Config {
                env: default_env(),
                port: default_port(),
                rust_log: default_rust_log(),
            }
        }
    }
}

※環境変数の値をまとめたコンフィグ設定です。

 

・「src/config/mod.rs」

pub mod config_settings;

 

・「src/application/usecase/context/context_request.rs」

// axum
use axum::{extract::Request, http::header::HeaderMap};

// 共通コンテキストの構造体
#[derive(Clone, Debug)]
pub struct ContextRequest {
    pub header: HeaderMap,
    pub method: String,
    pub uri: String,
}

// リクエスト用コンテキストの作成
pub fn new_context_request(req: &Request) -> ContextRequest {
    let mut hm = HeaderMap::new();
    for (key, value) in req.headers().iter() {
        hm.insert(key.clone(), value.clone());
    }

    ContextRequest {
        header: hm,
        method: req.method().to_string(),
        uri: req.uri().to_string(),
    }
}

※リクエスト単位で共有させたいコンテキスト情報です。

 

・「src/application/usecase/context/mod.rs」

pub mod context_request;

 

・「src/application/usecase/logger/logger_trait.rs」

// 共通コンテキスト
use crate::application::usecase::context::context_request::ContextRequest;

// ロガーのトレイト(モック化もできるように定義)
#[mockall::automock]
#[async_trait::async_trait]
pub trait LoggerTrait: Send + Sync {
    fn info(&self, ctx: &ContextRequest, msg: &str);
    #[allow(dead_code)]
    fn warn(&self, ctx: &ContextRequest, msg: &str);
    #[allow(dead_code)]
    fn error(&self, ctx: &ContextRequest, msg: &str);
}

※ロガーの実装はインフラストラクチャ層で行います。

 

・「src/application/usecase/logger/mod.rs」

pub mod logger_trait;

 

・「src/application/usecase/mod.rs」

pub mod context;
pub mod logger;

 

・「src/application/mod.rs」

pub mod usecase;

 

・「src/infrastructure/logger/logger_log.rs」

use chrono::TimeZone;
use std::io::Write;

// ロガー用のトレイト
use crate::application::usecase::logger::logger_trait::LoggerTrait;

// 共通コンテキスト
use crate::application::usecase::context::context_request::ContextRequest;

// ロガーの構造体
#[derive(Clone)]
pub struct Logger {}

impl Logger {
    // ロガーの初期化処理
    pub fn init() {
        // 日本時間を取得
        let jst = chrono::offset::FixedOffset::east_opt(9 * 3600)
            .unwrap()
            .from_utc_datetime(&chrono::Utc::now().naive_utc());

        // カスタムロガーの初期化
        env_logger::builder()
            .format(move |buf, record| {
                writeln!(
                    buf,
                    "{} {} {}",
                    jst.format("%Y-%m-%d %H:%M:%S"),
                    record.level(),
                    record.args()
                )
            })
            .init();
    }

    // インスタンス生成
    pub fn new() -> Self {
        Logger {}
    }

    // コンテキストからリクエスト情報取得
    fn get_req_info_from_ctx(ctx: &ContextRequest) -> String {
        // リクエストヘッダーから「X-Request-Id」を取得
        let x_request_id = ctx.header.get("X-Request-Id");
        let request_id = x_request_id.expect("-").to_str().unwrap();

        format!(
            "request_id={} method={} uri={}",
            request_id, ctx.method, ctx.uri
        )
    }
}

#[async_trait::async_trait]
impl LoggerTrait for Logger {
    fn info(&self, ctx: &ContextRequest, msg: &str) {
        let req_info = Logger::get_req_info_from_ctx(ctx);
        log::info!("[{}] {}", req_info, msg);
    }

    fn warn(&self, ctx: &ContextRequest, msg: &str) {
        let req_info = Logger::get_req_info_from_ctx(ctx);
        log::warn!("[{}] {}", req_info, msg);
    }

    fn error(&self, ctx: &ContextRequest, msg: &str) {
        let req_info = Logger::get_req_info_from_ctx(ctx);
        log::error!("[{}] {}", req_info, msg);
    }
}

 

・「src/infrastructure/logger/mod.rs」

pub mod logger_log;

 

・「src/infrastructure/database/database_dummy.rs」

// ダミーのDB接続
pub async fn new_db_dummy_connection() -> Result<String, ()> {
    Ok("dummy".to_string())
}

※今回は直接DBを使わないためダミー

 

・「src/infrastructure/database/mod.rs」

pub mod database_dummy;

 

・「src/infrastructure/mod.rs」

pub mod database;
pub mod logger;

 

・「src/presentation/middleware/common_middleware.rs」

// axum
use axum::{extract::Request, middleware::Next, response::Response};

// UUID
use uuid::Uuid;

// 共通コンテキストのモジュール
use crate::application::usecase::context::context_request;

// ロガー設定
use crate::application::usecase::logger::logger_trait::LoggerTrait;
use crate::infrastructure::logger::logger_log::Logger;

// リクエスト用のミドルウェア
pub async fn request_middleware(mut req: Request, next: Next) -> Response {
    // リクエストヘッダー「X-Request-Id」にUUIDを設定
    let request_id = Uuid::new_v4().to_string();
    req.headers_mut()
        .insert("X-Request-Id", request_id.parse().unwrap());

    // リクエストに共通コンテキストのExtentionを追加
    let ctx = context_request::new_context_request(&req);
    req.extensions_mut().insert(ctx.clone());

    // リクエスト単位でログ出力
    let logger = Logger::new();
    logger.info(&ctx, "start request !!");

    next.run(req).await
}

※リクエスト単位で一意のIDを付与するミドルウェア

 

・「src/presentation/middleware/mod.rs」

pub mod common_middleware;

 

・「src/presentation/mod.rs」

pub mod middleware;

 

・「src/domain/error/error_common.rs」

use axum::http::StatusCode;
use thiserror::Error;

#[derive(Clone, Error, Debug)]
pub enum ErrorCommon {
    #[allow(dead_code)]
    #[error("Internal Server Error")]
    InternalServerError,
    #[allow(dead_code)]
    #[error("{message}")]
    CustomError {
        status_code: StatusCode,
        message: String,
    },
}

※リポジトリの戻り値などで使う共通エラー定義

 

・「src/domain/error/mod.rs」

pub mod error_common;

 

・「src/domain/mod.rs」

pub mod error;

 

スポンサーリンク

DDD(ドメイン駆動設計)のディレクトリ構成について

この後にDDD(ドメイン駆動設計)でAPIを作成していきますが、ディレクトリ構成としてはDDDの思想に基づいたレイヤードアーキテクチャを採用しています。

/src
 ├── /application(アプリケーション層)
 |    └── usecase(ユースケース層)
 |
 ├── /config(コンフィグ設定)
 |
 ├── /domain(ドメイン層)
 |    ├── model(ドメインモデルの定義。ビジネスロジックは可能な限りドメインに集約させる。)
 |    ├── repository(リポジトリのインターフェース定義)
 |    └── (仮)service(外部サービスのインターフェース定義)
 |
 ├── /infrastructure(インフラストラクチャ層)
 |    ├── database(データベース設定)
 |    ├── logger(ロガーの実装。インターフェース部分はユースケース層で定義。)
 |    ├── persistence(リポジトリの実装。DB操作による永続化層。)
 |    ├── (仮)cache(キャッシュを含めたリポジトリの実装。インターフェースはリポジトリと同一。)
 |    └── (仮)externalapi(外部サービスの実装)
 |
 ├── /presentation(プレゼンテーション層)
 |    ├── handler(ハンドラー層。ルーターで設定したAppStateから対象のユースケースを実行。)
 |    ├── middleware(ミドルウェアの定義)
 |    └── router(ルーター設定。ハンドラーとレジストリのAppStateを利用。)
 |
 └── /registry(レジストリ。依存注入によるユースケースのインスタンスをAppStateにまとめる。)

※(仮)のものは将来的に追加する想定の例です。

 

ユーザードメインを例にAPIを作る

次に以下の手順でユーザードメインを例にAPIを作成します。

 

ドメインの定義

まずは以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir -p src/domain/user
$ touch src/domain/user/user_model.rs src/domain/user/user_model_test.rs src/domain/user/user_repository.rs src/domain/user/mod.rs

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/domain/user/user_model.rs」

use chrono::{DateTime, FixedOffset, TimeZone};
use serde::{Deserialize, Serialize};

// ユーザーモデルの定義
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct User {
    pub id: i64,
    pub uid: String,
    pub last_name: String,
    pub first_name: String,
    pub email: String,
    pub created_at: DateTime<FixedOffset>,
    pub updated_at: DateTime<FixedOffset>,
    pub deleted_at: Option<DateTime<FixedOffset>>,
}

impl User {
    // 新規作成
    #[allow(dead_code)]
    pub fn new(
        new_uid: String,
        new_last_name: String,
        new_first_name: String,
        new_email: String,
    ) -> Self {
        // jstの設定
        let jst_offset = FixedOffset::east_opt(9 * 3600).unwrap();

        // 現在日時の設定
        let utc_now = chrono::Utc::now();
        let jst_now = jst_offset.from_utc_datetime(&utc_now.naive_utc());

        Self {
            id: 0,
            uid: new_uid,
            last_name: new_last_name,
            first_name: new_first_name,
            email: new_email,
            created_at: jst_now,
            updated_at: jst_now,
            deleted_at: None,
        }
    }

    // プロフィール更新
    #[allow(dead_code)]
    pub fn update_profile(
        &mut self,
        last_name: String,
        first_name: String,
        email: String,
    ) -> Result<(), String> {
        // パラメータチェック
        let mut err_msgs = Vec::new();
        if last_name.is_empty() {
            err_msgs.push("last_nameは必須です。");
        }
        if first_name.is_empty() {
            err_msgs.push("first_nameは必須です。");
        }
        if email.is_empty() {
            err_msgs.push("emailは必須です。");
        }
        if !err_msgs.is_empty() {
            let msg = err_msgs.join(", ");

            return Err(msg);
        }

        // 対象項目の更新
        self.last_name = last_name;
        self.first_name = first_name;
        self.email = email;

        // 現在日時の設定
        let jst_offset = FixedOffset::east_opt(9 * 3600).unwrap();
        let utc_now = chrono::Utc::now();
        let jst_now = jst_offset.from_utc_datetime(&utc_now.naive_utc());

        // 更新日時の更新
        self.updated_at = jst_now;

        Ok(())
    }

    // 論理削除設定
    #[allow(dead_code)]
    pub fn set_delete(&mut self) {
        // 現在日時の設定
        let jst_offset = FixedOffset::east_opt(9 * 3600).unwrap();
        let utc_now = chrono::Utc::now();
        let jst_now = jst_offset.from_utc_datetime(&utc_now.naive_utc());

        // 更新日時と削除日時の更新
        self.updated_at = jst_now;
        self.deleted_at = Some(jst_now);
    }
}

※ドメインに関するビジネスロジックについては、可能な限りドメインのメソッドして定義するようにします。

 

・「src/domain/user/user_model_test.rs」

#[cfg(test)]
mod tests {
use crate::domain::user::user_model::User;
use chrono::{FixedOffset, Utc};

    #[test]
    fn test_new_user() {
        // ユーザーのパラメータ
        let uid = "xxx-xxx-xxx-0001".to_string();
        let last_name = "テスト".to_string();
        let first_name = "太郎".to_string();
        let email = "t.test@example.com".to_string();

        // テスト実行
        let user = User::new(
            uid.clone(),
            last_name.clone(),
            first_name.clone(),
            email.clone(),
        );

        // 検証
        assert_eq!(user.id, 0);
        assert_eq!(user.uid, uid);
        assert_eq!(user.last_name, last_name);
        assert_eq!(user.first_name, first_name);
        assert_eq!(user.email, email);
        assert!(
            user.created_at <= Utc::now().with_timezone(&FixedOffset::east_opt(9 * 3600).unwrap())
        );
        assert_eq!(user.created_at, user.updated_at);
        assert!(user.deleted_at.is_none());
    }

    #[test]
    fn test_update_profile_success() {
        // ユーザーのパラメータ
        let uid = "xxx-xxx-xxx-0001".to_string();
        let last_name = "テスト".to_string();
        let first_name = "太郎".to_string();
        let email = "t.test@example.com".to_string();

        // ユーザー作成
        let mut user = User::new(
            uid.clone(),
            last_name.clone(),
            first_name.clone(),
            email.clone(),
        );

        // 更新するプロフィール
        let new_last_name = "テスト2".to_string();
        let new_first_name = "二郎".to_string();
        let new_email = "t.test2@example.com".to_string();

        // テスト実行
        let result = user.update_profile(
            new_last_name.clone(),
            new_first_name.clone(),
            new_email.clone(),
        );

        // 検証
        assert!(result.is_ok());
        assert_eq!(user.uid, uid);
        assert_eq!(user.last_name, new_last_name);
        assert_eq!(user.first_name, new_first_name);
        assert_eq!(user.email, new_email);
        assert!(user.updated_at > user.created_at);
        assert!(user.deleted_at.is_none());
    }

    #[test]
    fn test_update_profile_error() {
        // ユーザーのパラメータ
        let uid = "xxx-xxx-xxx-0001".to_string();
        let last_name = "テスト".to_string();
        let first_name = "太郎".to_string();
        let email = "t.test@example.com".to_string();

        // ユーザー作成
        let mut user = User::new(
            uid.clone(),
            last_name.clone(),
            first_name.clone(),
            email.clone(),
        );

        // 更新するプロフィール
        let new_last_name = "".to_string();
        let new_first_name = "".to_string();
        let new_email = "".to_string();

        // テスト実行
        let result = user.update_profile(
            new_last_name.clone(),
            new_first_name.clone(),
            new_email.clone(),
        );

        // 検証
        assert!(result.is_err());
        assert_eq!(
            result.unwrap_err(),
            "last_nameは必須です。, first_nameは必須です。, emailは必須です。"
        );
        assert_eq!(user.uid, uid);
        assert_eq!(user.last_name, last_name);
        assert_eq!(user.first_name, first_name);
        assert_eq!(user.email, email);
        assert!(user.updated_at == user.created_at);
        assert!(user.deleted_at.is_none());
    }

    #[test]
    fn test_set_delete_success() {
        // ユーザーのパラメータ
        let uid = "xxx-xxx-xxx-0001".to_string();
        let last_name = "テスト".to_string();
        let first_name = "太郎".to_string();
        let email = "t.test@example.com".to_string();

        // ユーザー作成
        let mut user = User::new(
            uid.clone(),
            last_name.clone(),
            first_name.clone(),
            email.clone(),
        );

        // テスト実行
        user.set_delete();

        // 検証
        assert_eq!(user.uid, uid);
        assert_eq!(user.last_name, last_name);
        assert_eq!(user.first_name, first_name);
        assert_eq!(user.email, email);
        assert!(user.updated_at > user.created_at);
        assert!(user.deleted_at.is_some());
    }
}

 

・「src/domain/user/user_repository.rs」

// 共通コンテキスト
use crate::application::usecase::context::context_request::ContextRequest;

// ドメイン
use crate::domain::{error::error_common::ErrorCommon, user::user_model::User};

// Userリポジトリ用のトレイト(モック化もできるように定義)
// Send: オブジェクトが異なるスレッド間で安全に送信できることを保証
// Sync: オブジェクトが複数のスレッドから同時にアクセスできることを保証
#[mockall::automock]
#[async_trait::async_trait]
pub trait UserRepositoryTrait: Send + Sync {
    async fn find_all(&self, ctx: &ContextRequest) -> Result<Vec<User>, ErrorCommon>;
}

※DB操作に関するものはリポジトリのインターフェースを定義します。実装は後述のインフラストラクチャ層で行います。また「Send + Sync」はトレイトで定義できます。

 

・「src/domain/user/mod.rs」

pub mod user_model;
pub mod user_repository;

// テストコード用のモジュール
pub mod user_model_test;

 

次にファイル「src/domain/mod.rs」を以下のように修正します。

pub mod error;
pub mod user;

 

リポジトリの実装

次にDB操作に関するリポジトリの実装をするため、以下のコマンドを実行してファイルを作成します。

$ mkdir -p src/infrastructure/persistence/user
$ touch src/infrastructure/persistence/user/user_repository.rs src/infrastructure/persistence/user/mod.rs
$ touch src/infrastructure/persistence/mod.rs

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/infrastructure/persistence/user/user_repository.rs」

use chrono::{FixedOffset, TimeZone};

// Arc(ヒープ上に確保されたある値の所有権を、複数のスレッド間で安全に共有するためのスマートポインタ)
use std::sync::Arc;

// 共通コンテキスト
use crate::application::usecase::context::context_request::ContextRequest;

// ロガー
use crate::application::usecase::logger::logger_trait::LoggerTrait;

// ドメイン
use crate::domain::{
    error::error_common::ErrorCommon, user::user_model::User,
    user::user_repository::UserRepositoryTrait,
};

// ユーザーリポジトリの構造体
pub struct UserRepository {
    pub _db: String, // TODO: 仮でString型にしているが、DBインスタンスに合わせた型に変更する
    // Arc<T>型で動的にメモリ領域確保(スレッドセーフな共有所有権)
    // 'static: オブジェクトのライフタイムがプログラムが終了するまで破棄されない
    pub _logger: Arc<dyn LoggerTrait + 'static>,
}

impl UserRepository {
    // 初期化用メソッド
    pub fn new(db: String, logger: Arc<dyn LoggerTrait + 'static>) -> Self {
        UserRepository {
            _db: db,
            _logger: logger,
        }
    }
}

#[async_trait::async_trait]
impl UserRepositoryTrait for UserRepository {
    // 全てのユーザー取得
    async fn find_all(&self, _ctx: &ContextRequest) -> Result<Vec<User>, ErrorCommon> {
        // *今回はDBを直接使わないため、固定値を返す*

        // jstの設定
        let jst_offset = FixedOffset::east_opt(9 * 3600).unwrap();

        // 固定日付1
        let specific_datetime_1 = jst_offset.with_ymd_and_hms(2025, 7, 26, 7, 10, 10).unwrap();

        // 固定日付2
        let specific_datetime_2 = jst_offset.with_ymd_and_hms(2025, 7, 27, 8, 30, 0).unwrap();

        // ユーザーデータ
        let users: Vec<User> = vec![
            User {
                id: 1,
                uid: "xxxx-xxxx-xxxx-0001".to_string(),
                last_name: "田中".to_string(),
                first_name: "太郎".to_string(),
                email: "t.tanaka@example.com".to_string(),
                created_at: specific_datetime_1,
                updated_at: specific_datetime_1,
                deleted_at: None,
            },
            User {
                id: 2,
                uid: "xxxx-xxxx-xxxx-0002".to_string(),
                last_name: "佐藤".to_string(),
                first_name: "二郎".to_string(),
                email: "z.satou@example.com".to_string(),
                created_at: specific_datetime_2,
                updated_at: specific_datetime_2,
                deleted_at: None,
            },
        ];

        Ok(users)
    }
}

※今回は直接DBは使わないため、リポジトリの実装は固定値を返すようにしたサンプルです。実際にDB操作を行う場合は、それに関するテストコードも書くようにして下さい。また依存注入できるようにするためにArc(ヒープ上に確保されたある値の所有権を、複数のスレッド間で安全に共有するためのスマートポインタ)を使う必要があります。

 

・「src/infrastructure/persistence/user/mod.rs」

pub mod user_repository;

 

・「src/infrastructure/persistence/mod.rs」

pub mod user;

 

次にファイル「src/infrastructure/mod.rs」を以下のように修正します。

pub mod database;
pub mod logger;
pub mod persistence;

 

ユースケースの定義

次にユースケースを定義するため、以下のコマンドを実行して各種ファイルを作成します。

$ mkdir -p src/application/usecase/user
$ touch src/application/usecase/user/user_find_all.rs src/application/usecase/user/user_find_all_test.rs src/application/usecase/user/mod.rs

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/application/usecase/user/user_find_all.rs」

// axum
use axum::{
    Json,
    http::StatusCode,
    response::{IntoResponse, Response},
};

// Arc(ヒープ上に確保されたある値の所有権を、複数のスレッド間で安全に共有するためのスマートポインタ)
use std::sync::Arc;

// json変換用マクロ
use serde_json::json;

// 共通コンテキスト
use crate::application::usecase::context::context_request::ContextRequest;

// ドメイン
use crate::domain::error::error_common::ErrorCommon;
use crate::domain::user::user_repository::UserRepositoryTrait;

// ロガー
use crate::application::usecase::logger::logger_trait::LoggerTrait;

// ユースケース用のトレイト(モック化もできるように定義)
#[mockall::automock]
#[async_trait::async_trait]
pub trait UserFindAllUsecaseTrait {
    async fn exec(&self, ctx: ContextRequest) -> Response;
}

// 使用するリポジトリをまとめる構造体
#[derive(Clone)]
pub struct UserFindAllRepository {
    // Arc<T>型で動的にメモリ領域確保(スレッドセーフな共有所有権)
    // 'static: オブジェクトのライフタイムがプログラムが終了するまで破棄されない
    pub user_repository: Arc<dyn UserRepositoryTrait + 'static>,
}

// ユースケースの構造体
#[derive(Clone)]
pub struct UserFindAllUsecase {
    pub repo: UserFindAllRepository,
    pub logger: Arc<dyn LoggerTrait + 'static>,
}

impl UserFindAllUsecase {
    pub fn new(repo: UserFindAllRepository, logger: Arc<dyn LoggerTrait + 'static>) -> Self {
        UserFindAllUsecase { repo, logger }
    }
}

#[async_trait::async_trait]
impl UserFindAllUsecaseTrait for UserFindAllUsecase {
    async fn exec(&self, ctx: ContextRequest) -> Response {
        // レスポンスヘッダーに付与する値の設定
        let x_request_id = ctx.header.get("X-Request-Id");
        let request_id = x_request_id.expect("-").to_str().unwrap();
        let res_header = [("X-Request-Id", request_id)];

        // 全てのユーザー取得処理
        let users = match self.repo.user_repository.find_all(&ctx).await {
            Ok(users) => users,
            Err(err) => {
                // エラーログ出力
                let err_msg = format!("UserFindAllUsecaseでエラー: {}", err);
                self.logger.error(&ctx, &err_msg);

                // json形式のメッセージを設定
                let json_msg = Json(json!({ "message": err.to_string()}));

                // ステータスコードの設定
                let status_code = match err {
                    ErrorCommon::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
                    ErrorCommon::CustomError { status_code, .. } => status_code,
                };

                // レスポンス結果の設定
                let res = (status_code, res_header, json_msg).into_response();

                // 戻り値としてレスポンス結果を返す
                return res;
            }
        };

        // レスポンスボディの設定
        let res_body = Json(json!(users));

        // レスポンス結果を設定して戻り値として返す
        (StatusCode::OK, res_header, res_body).into_response()
    }
}

 

・「src/application/usecase/user/user_find_all_test.rs」

#[cfg(test)]
mod tests {
    use axum::{
        body::to_bytes,
        http::{HeaderMap, StatusCode},
    };
    use chrono::{FixedOffset, Utc};
    use std::{str, sync::Arc};

    // 共通コンテキスト
    use crate::application::usecase::context::context_request::ContextRequest;

    // ドメイン
    use crate::domain::{error::error_common::ErrorCommon, user::user_model::User};

    // ロガーのモック
    use crate::application::usecase::logger::logger_trait::MockLoggerTrait;

    // リポジトリのモック
    use crate::domain::user::user_repository::MockUserRepositoryTrait;

    // ユースケース
    use crate::application::usecase::user::user_find_all::UserFindAllRepository;
    use crate::application::usecase::user::user_find_all::UserFindAllUsecase;
    use crate::application::usecase::user::user_find_all::UserFindAllUsecaseTrait;

    #[tokio::test]
    async fn test_exec_success() {
        // ロガーのモック化
        let mut mock_logger = MockLoggerTrait::new();
        mock_logger.expect_info().returning(|_, _| ());

        // リポジトリのモック化
        let mut mock_user_repo = MockUserRepositoryTrait::new();
        let now = Utc::now().with_timezone(&FixedOffset::east_opt(9 * 3600).unwrap());
        let users = vec![
            User {
                id: 1,
                uid: "xxxx-xxxx-xxxx-0001".to_string(),
                last_name: "田中".to_string(),
                first_name: "太郎".to_string(),
                email: "t.tanaka@example.com".to_string(),
                created_at: now,
                updated_at: now,
                deleted_at: None,
            },
            User {
                id: 2,
                uid: "xxxx-xxxx-xxxx-0002".to_string(),
                last_name: "佐藤".to_string(),
                first_name: "二郎".to_string(),
                email: "z.satou@example.com".to_string(),
                created_at: now,
                updated_at: now,
                deleted_at: None,
            },
        ];
        mock_user_repo
            .expect_find_all()
            .returning(move |_| Ok(users.clone()));

        // ユースケースのインスタンス化
        let user_find_all_usecase = UserFindAllUsecase {
            repo: UserFindAllRepository {
                user_repository: Arc::new(mock_user_repo),
            },
            logger: Arc::new(mock_logger),
        };

        // 共通コンテキスト設定
        let mut h = HeaderMap::new();
        h.insert("X-Request-Id", "xxx-yyy-zzz-001".parse().unwrap());

        let ctx = ContextRequest {
            header: h,
            method: "GET".to_string(),
            uri: "/api/v1/users".to_string(),
        };

        // テスト実行
        let res = user_find_all_usecase.exec(ctx).await;

        // 検証
        assert_eq!(res.status(), StatusCode::OK);

        // レスポンスボディの検証
        let body = res.into_body();
        let bytes = to_bytes(body, usize::MAX).await.unwrap();
        let body_str = std::str::from_utf8(&bytes).unwrap();
        let res_data: Vec<User> = serde_json::from_str(body_str).unwrap();
        assert_eq!(res_data.len(), 2);

        assert_eq!(res_data[0].id, 1);
        assert_eq!(res_data[0].uid, "xxxx-xxxx-xxxx-0001".to_string());
        assert_eq!(res_data[0].last_name, "田中".to_string());
        assert_eq!(res_data[0].first_name, "太郎".to_string());
        assert_eq!(res_data[0].email, "t.tanaka@example.com".to_string());
        assert!(
            res_data[0].created_at
            <= Utc::now().with_timezone(&FixedOffset::east_opt(9 * 3600).unwrap())
        );
        assert_eq!(res_data[0].updated_at, res_data[0].created_at);
        assert!(res_data[0].deleted_at.is_none());

        assert_eq!(res_data[1].id, 2);
        assert_eq!(res_data[1].uid, "xxxx-xxxx-xxxx-0002".to_string());
        assert_eq!(res_data[1].last_name, "佐藤".to_string());
        assert_eq!(res_data[1].first_name, "二郎".to_string());
        assert_eq!(res_data[1].email, "z.satou@example.com".to_string());
        assert!(
            res_data[1].created_at
            <= Utc::now().with_timezone(&FixedOffset::east_opt(9 * 3600).unwrap())
        );
        assert_eq!(res_data[1].updated_at, res_data[1].created_at);
        assert!(res_data[1].deleted_at.is_none());
    }

    #[tokio::test]
    async fn test_exec_error() {
        // ロガーのモック化
        let mut mock_logger = MockLoggerTrait::new();
        mock_logger.expect_error().returning(|_, _| ());

        // リポジトリのモック化
        let mut mock_user_repo = MockUserRepositoryTrait::new();
        let err = ErrorCommon::InternalServerError;
        mock_user_repo
            .expect_find_all()
            .returning(move |_| Err(err.clone()));

        // ユースケースのインスタンス化
        let user_find_all_usecase = UserFindAllUsecase {
            repo: UserFindAllRepository {
                user_repository: Arc::new(mock_user_repo),
            },
            logger: Arc::new(mock_logger),
        };

        // 共通コンテキスト設定
        let mut h = HeaderMap::new();
        h.insert("X-Request-Id", "xxx-yyy-zzz-001".parse().unwrap());

        let ctx = ContextRequest {
            header: h,
            method: "GET".to_string(),
            uri: "/api/v1/users".to_string(),
        };

        // テスト実行
        let res = user_find_all_usecase.exec(ctx).await;

        // 検証
        assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);

        // レスポンスボディの検証
        let body = res.into_body();
        let bytes = to_bytes(body, usize::MAX).await.unwrap();
        let body_str = str::from_utf8(&bytes).unwrap();
        assert!(body_str.contains("Internal Server Error"));
    }
}

 

・「src/application/usecase/user/mod.rs」

pub mod user_find_all;

// テストコード用のモジュール
pub mod user_find_all_test;

 

次にファイル「src/application/usecase/mod.rs」を以下のように修正します。

pub mod context;
pub mod logger;
pub mod user;

 

レジストリ登録

次にルーター設定およびハンドラー定義で利用するユースケースのインスタンスをまとめるため、以下のコマンドを実行してレジストリ登録用のファイルを作成します。

$ mkdir -p src/registry
$ touch src/registry/registry_settings.rs src/registry/mod.rs

 

・「src/registry/registry_settings.rs」

use std::sync::Arc;

// DB
use crate::infrastructure::database::database_dummy::new_db_dummy_connection;

// ロガー
use crate::infrastructure::logger::logger_log::Logger;

// リポジトリ
use crate::infrastructure::persistence::user::user_repository::UserRepository;

// ユースケース
use crate::application::usecase::user::user_find_all::UserFindAllRepository;
use crate::application::usecase::user::user_find_all::UserFindAllUsecase;

// Userユースケース
#[derive(Clone)]
pub struct UserUsecase {
    pub user_find_all: UserFindAllUsecase,
}

// アプリケーション全体で共有する状態(DIコンテナ)
#[derive(Clone)]
pub struct AppState {
    pub user_usecase: UserUsecase,
}

impl AppState {
    pub async fn new() -> Self {
        // DB設定
        let db = new_db_dummy_connection().await.unwrap();

        // ロガー設定
        let logger = Arc::new(Logger::new());

        // リポジトリのインスタンス化
        let user_repo = Arc::new(UserRepository::new(db, logger.clone()));

        // Userユースケースのインスタンス化とまとめ
        let user_find_all_repo = UserFindAllRepository {
            user_repository: user_repo.clone(),
        };
        let user_find_all_usecase = UserFindAllUsecase::new(user_find_all_repo, logger.clone());
        let user_usecase = UserUsecase {
            user_find_all: user_find_all_usecase,
        };

        // 戻り値の設定
        Self { user_usecase }
    }
}

 

・「src/registry/mod.rs」

pub mod registry_settings;

 

ハンドラーの定義

次に以下のコマンドを実行し、ハンドラーのテストコードで利用するクレート(ライブラリ)を追加します。

$ docker compose run --rm api cargo add reqwest --features json

 

次にハンドラーを定義するため、以下のコマンドを実行して各種ファイルを作成します。

$ mkdir -p src/presentation/handler/user
$ touch src/presentation/handler/user/user_handler.rs src/presentation/handler/user/user_handler_test.rs src/presentation/handler/user/mod.rs
$ touch src/presentation/handler/mod.rs

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/presentation/handler/user/user_handler.rs」

// axum
use axum::{
    extract::{Extension, State},
    response::Response,
};

// Arc(ヒープ上に確保されたある値の所有権を、複数のスレッド間で安全に共有するためのスマートポインタ)
use std::sync::Arc;

// レジストリ
use crate::registry::registry_settings::AppState;

// 共通コンテキスト
use crate::application::usecase::context::context_request::ContextRequest;

// ユースケースのトレイト
use crate::application::usecase::user::user_find_all::UserFindAllUsecaseTrait;

// ハンドラー
// 全てのユーザー取得
pub async fn find_all(
    State(state): State<Arc<AppState>>,
    Extension(ctx): Extension<ContextRequest>,
) -> Response {
    // ユースケースを実行
    state.user_usecase.user_find_all.exec(ctx).await
}

 

・「src/presentation/handler/user/user_handler_test.rs」

#[cfg(test)]
mod tests {
    use crate::domain::user::user_model::User;

    #[tokio::test]
    async fn test_response_ok() {
        // リクエストを実行
        let url = "http://localhost:8080/api/v1/users";
        let client = reqwest::Client::new();
        let res = client.get(url).send().await.unwrap();

        // レスポンスステータスの検証
        assert_eq!(res.status(), 200);

        // レスポンスボディの検証
        let text_body = res.text().await.unwrap();
        let req_body: Vec<User> = serde_json::from_str(&text_body).unwrap();
        assert_eq!(req_body.len(), 2);

        assert_eq!(req_body[0].id, 1);
        assert_eq!(req_body[0].uid, "xxxx-xxxx-xxxx-0001");
        assert_eq!(req_body[0].last_name, "田中");
        assert_eq!(req_body[0].first_name, "太郎");
        assert_eq!(req_body[0].email, "t.tanaka@example.com");
        assert_eq!(req_body[0].created_at, req_body[0].updated_at);
        assert!(req_body[0].deleted_at.is_none());

        assert_eq!(req_body[1].id, 2);
        assert_eq!(req_body[1].uid, "xxxx-xxxx-xxxx-0002");
        assert_eq!(req_body[1].last_name, "佐藤");
        assert_eq!(req_body[1].first_name, "二郎");
        assert_eq!(req_body[1].email, "z.satou@example.com");
        assert_eq!(req_body[1].created_at, req_body[1].updated_at);
        assert!(req_body[1].deleted_at.is_none());
    }
}

 

・「src/presentation/handler/user/mod.rs」

pub mod user_handler;

// テストコード用のモジュール
pub mod user_handler_test;

 

・「src/presentation/handler/mod.rs」

pub mod user;

 

ルーター設定

次にルーター設定をするため、以下のコマンドを実行してファイルを作成します。

$ mkdir -p src/presentation/router
$ touch src/presentation/router/router_settings.rs src/presentation/router/mod.rs

 

・「src/presentation/router/router_settings.rs」

// axum
use axum::{Router, middleware, routing::get};

// Arc(ヒープ上に確保されたある値の所有権を、複数のスレッド間で安全に共有するためのスマートポインタ)
use std::sync::Arc;

// tower_http
use tower_http::trace::TraceLayer;

// レジストリ
use crate::registry::registry_settings::AppState;

// ハンドラー
use crate::presentation::handler::user::user_handler;

// ミドルウェア
use crate::presentation::middleware::common_middleware;

pub fn router(state: Arc<AppState>) -> Router {
    // グループ設定「v1」
    let v1 = Router::new().route("/users", get(user_handler::find_all));

    // ルーター設定
    Router::new()
        .nest("/api/v1", v1)
        // 共通ミドルウェアの設定(下から順番に読み込み)
        .layer(middleware::from_fn(common_middleware::request_middleware))
        .layer(TraceLayer::new_for_http().on_response(
            |res: &axum::response::Response,
            latency: std::time::Duration,
            _span: &tracing::Span| {
                // レスポンスヘッダーからX-Request-Idを取得
                let request_id = match res.headers().get("X-Request-Id") {
                    Some(value) => value.to_str().unwrap_or("-").to_string(),
                    None => "-".to_string(),
                };

                // ログ出力
                log::info!(
                    "[request_id={} status=({}) latency={}μs] finish request !!",
                    request_id,
                    res.status(),
                    latency.as_micros()
                )
            },
        ))
        .with_state(state)
}

 

・「src/presentation/router/mod.rs」

pub mod router_settings;

 

次にファイル「src/presentation/mod.rs」を以下のように修正します。

pub mod handler;
pub mod middleware;
pub mod router;

 

main.goの修正

次にファイル「src/main.go」を以下のように修正します。

// axum
use axum::serve;

// Arc(ヒープ上に確保されたある値の所有権を、複数のスレッド間で安全に共有するためのスマートポインタ)
use std::sync::Arc;

// モジュールのインポート
mod application;
mod config;
mod domain;
mod infrastructure;
mod presentation;
mod registry;

// コンフィグ設定
use crate::config::config_settings::get_config;

// ルーター設定
use crate::presentation::router::router_settings::router;

// ロガー設定
use crate::infrastructure::logger::logger_log::Logger;

// レジストリ設定
use crate::registry::registry_settings::AppState;

#[tokio::main]
async fn main() {
    // 環境変数取得
    let config = get_config();

    // ロガーの初期化
    Logger::init();

    // サーバー起動のログ出力
    log::info!("Start rust_axum_domain (ENV:{}) !!", config.env);

    // サーバー起動
    let state = Arc::new(AppState::new().await);
    let app = router(state);
    let addr = format!("0.0.0.0:{}", config.port);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    serve(listener, app).await.unwrap();
}

 

次に以下のコマンドを実行し、フォーマット修正および静的コード解析を行い、警告が出ないことを確認します。

$ docker compose run --rm api cargo fmt
$ docker compose run --rm api cargo clippy

 

コンテナの再ビルドと起動

次に以下のコマンドを実行し、コンテナを再びルドします。

$ docker compose build --no-cache

 

次に以下のコマンドを実行し、コンテナを起動します。

$ docker compose up -d

 

次に以下のコマンドを実行し、ログ出力を確認します。

$ docker compose logs

 

ログ出力を確認し、エラーがなければOKです。

 

スポンサーリンク

ユーザードメインのAPIを試す

次に上記で作成したユーザードメインのAPIをPostmanを使って試します。

GETメソッドで「http://localhost:8080/api/v1/users」を実行し、下図のようにステータスコード200で想定通りのレスポンス結果になればOKです。

 

テストコードの実行

次に以下のコマンドを実行し、上記で作成しておいたテストコードを試します。

$ docker compose exec -e CARGO_TEST=testing api cargo test -- --nocapture --test-threads=1

 

テスト実行後、以下のように全てのテストがPASSすればOKです。

 

データベースやOpenAPIについて

今回はデータベースやOpenAPIに関する部分は省略しています。必要な場合は以下の記事を参考にしてみて下さい。

RustのaxumでバックエンドAPIを開発する方法まとめ
こんにちは。Tomoyuki(@tomoyuki65)です。パフォーマンスやメモリ安全性を最重視してAPIを作りたい場合、「Rust」というプログラミング言語が候補に上がります。ただRustについてはまだまだ普及しておらず、学習コストも非常...

 

スポンサーリンク

最後に

今回はRustのaxumでDDD構成のバックエンドAPIを開発する方法について解説しました。

今後Rustが流行って実務で使われることが増えると、必ずドメイン駆動設計での開発が必要になると思います。

特にドメイン部分は専門性が問われ、実際にはもっと複雑になると思いますが、基本的な開発方法についてはまとめられたと思うので、RustによるDDD(ドメイン駆動設計)について学びたい方はぜひ参考にしてみて下さい!

 

この記事を書いた人
Tomoyuki

SE→ブロガーを経て、現在はWeb系エンジニアをしています!

Tomoyukiをフォローする
応用
スポンサーリンク
Tomoyukiをフォローする

コメント

タイトルとURLをコピーしました