📖

クリーンアーキテクチャ入門 Part 2: ビジネスロジックの設計(Domain層・Application層)

に公開

クリーンアーキテクチャ入門 Part 2: ビジネスロジックの設計(Domain層・Application層)

はじめに

Part1でクリーンアーキテクチャの基本概念を理解したら、次はビジネスロジックの設計について詳しく学んでいきます。この記事では、Domain層とApplication層の実装に焦点を当て、実際のコード例を通じて理解を深めていきます。

この記事で学べること:

  • Domain層でのビジネスルールの実装
  • Application層でのユースケースの実装
  • 依存性注入による疎結合の実現
  • 実際のデータフローとテスト方法

Domain層の詳細実装

Domain層は、アプリケーションの核心となるビジネスロジックを定義する場所です。ここでは、外部依存のない純粋なビジネスルールを実装します。

エンティティの実装

基本的なエンティティ

// src/domain/entities/task.rs
use chrono::{DateTime, Utc};
use std::error::Error;

#[derive(Debug, Clone)]
pub struct Task {
    pub id: Option<i32>,
    pub title: String,
    pub description: String,
    pub created_at: Option<DateTime<Utc>>,
    pub status: TaskStatus,
}

#[derive(Debug, Clone, PartialEq)]
pub enum TaskStatus {
    Pending,
    InProgress,
    Completed,
    Cancelled,
}

impl Task {
    // ビジネスルール:タスクの作成
    pub fn new(title: String, description: String) -> Result<Self, Box<dyn Error + Send + Sync>> {
        // バリデーションルール1: タイトルは空であってはならない
        if title.trim().is_empty() {
            return Err("Task title cannot be empty".into());
        }

        // バリデーションルール2: タイトルは100文字以内
        if title.len() > 100 {
            return Err("Task title must be 100 characters or less".into());
        }

        // バリデーションルール3: 説明は1000文字以内
        if description.len() > 1000 {
            return Err("Task description must be 1000 characters or less".into());
        }

        Ok(Task {
            id: None,
            title: title.trim().to_string(),
            description: description.trim().to_string(),
            created_at: Some(Utc::now()),
            status: TaskStatus::Pending,
        })
    }

    // ビジネスルール:タスクの状態変更
    pub fn start(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
        match self.status {
            TaskStatus::Pending => {
                self.status = TaskStatus::InProgress;
                Ok(())
            }
            TaskStatus::InProgress => Err("Task is already in progress".into()),
            TaskStatus::Completed => Err("Cannot start a completed task".into()),
            TaskStatus::Cancelled => Err("Cannot start a cancelled task".into()),
        }
    }

    pub fn complete(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
        match self.status {
            TaskStatus::Pending => Err("Cannot complete a pending task".into()),
            TaskStatus::InProgress => {
                self.status = TaskStatus::Completed;
                Ok(())
            }
            TaskStatus::Completed => Err("Task is already completed".into()),
            TaskStatus::Cancelled => Err("Cannot complete a cancelled task".into()),
        }
    }

    pub fn cancel(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
        match self.status {
            TaskStatus::Pending | TaskStatus::InProgress => {
                self.status = TaskStatus::Cancelled;
                Ok(())
            }
            TaskStatus::Completed => Err("Cannot cancel a completed task".into()),
            TaskStatus::Cancelled => Err("Task is already cancelled".into()),
        }
    }

    // ビジネスルール:タスクの検証
    pub fn is_editable(&self) -> bool {
        matches!(self.status, TaskStatus::Pending | TaskStatus::InProgress)
    }

    pub fn is_completed(&self) -> bool {
        self.status == TaskStatus::Completed
    }
}

Value Objects(値オブジェクト)

// src/domain/value_objects/task_title.rs
use std::error::Error;

#[derive(Debug, Clone)]
pub struct TaskTitle {
    value: String,
}

impl TaskTitle {
    pub fn new(value: String) -> Result<Self, Box<dyn Error + Send + Sync>> {
        let trimmed = value.trim();

        if trimmed.is_empty() {
            return Err("Task title cannot be empty".into());
        }

        if trimmed.len() > 100 {
            return Err("Task title must be 100 characters or less".into());
        }

        // ビジネスルール:禁止ワードのチェック
        let forbidden_words = vec!["spam", "advertisement", "inappropriate"];
        let lower_title = trimmed.to_lowercase();

        for word in forbidden_words {
            if lower_title.contains(word) {
                return Err(format!("Task title contains forbidden word: {}", word).into());
            }
        }

        Ok(TaskTitle {
            value: trimmed.to_string(),
        })
    }

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

impl From<TaskTitle> for String {
    fn from(title: TaskTitle) -> Self {
        title.value
    }
}

ドメインサービスの実装

// src/domain/services/task_domain_service.rs
use crate::domain::entities::{Task, User};

pub struct TaskDomainService;

impl TaskDomainService {
    // ビジネスルール:ユーザーがタスクを作成できるかチェック
    pub fn can_user_create_task(user: &User) -> Result<(), Box<dyn Error + Send + Sync>> {
        if !user.is_active() {
            return Err("Inactive users cannot create tasks".into());
        }

        if user.task_count() >= 100 {
            return Err("User has reached the maximum number of tasks (100)".into());
        }

        Ok(())
    }

    // ビジネスルール:タスクの優先度計算
    pub fn calculate_priority(task: &Task, user: &User) -> TaskPriority {
        let base_priority = match task.status {
            TaskStatus::Pending => 1,
            TaskStatus::InProgress => 2,
            TaskStatus::Completed => 0,
            TaskStatus::Cancelled => 0,
        };

        // ユーザーのVIPレベルに応じて優先度を調整
        let user_multiplier = match user.vip_level() {
            VipLevel::Standard => 1,
            VipLevel::Premium => 2,
            VipLevel::Enterprise => 3,
        };

        TaskPriority(base_priority * user_multiplier)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct TaskPriority(pub u32);

リポジトリインターフェース

// src/domain/repositories/task_repository.rs
use async_trait::async_trait;
use std::error::Error;
use crate::domain::entities::Task;

#[async_trait]
pub trait TaskRepository {
    async fn save(&self, task: &Task) -> Result<Task, Box<dyn Error + Send + Sync>>;
    async fn find_by_id(&self, id: i32) -> Result<Option<Task>, Box<dyn Error + Send + Sync>>;
    async fn find_by_user_id(&self, user_id: i32) -> Result<Vec<Task>, Box<dyn Error + Send + Sync>>;
    async fn update(&self, task: &Task) -> Result<Task, Box<dyn Error + Send + Sync>>;
    async fn delete(&self, id: i32) -> Result<(), Box<dyn Error + Send + Sync>>;

    // ビジネスルール:重複チェック
    async fn exists_by_title_and_user(&self, title: &str, user_id: i32) -> Result<bool, Box<dyn Error + Send + Sync>>;
}

Application層の詳細実装

Application層は、Domain層のビジネスロジックを組み合わせて、特定のビジネスシナリオを実現します。

基本的なユースケース

// src/application/use_cases/task/add_task/add_task_use_case.rs
use std::sync::Arc;
use crate::domain::entities::Task;
use crate::domain::repositories::TaskRepository;
use crate::domain::services::TaskDomainService;

pub struct AddTaskUseCase {
    task_repository: Arc<dyn TaskRepository + Send + Sync>,
    user_repository: Arc<dyn UserRepository + Send + Sync>,
}

impl AddTaskUseCase {
    pub fn new(
        task_repository: Arc<dyn TaskRepository + Send + Sync>,
        user_repository: Arc<dyn UserRepository + Send + Sync>,
    ) -> Self {
        Self {
            task_repository,
            user_repository,
        }
    }

    pub async fn execute(&self, input: AddTaskInput) -> Result<AddTaskOutput, Box<dyn Error + Send + Sync>> {
        // 1. ユーザーの存在確認と権限チェック
        let user = self.user_repository.find_by_id(input.user_id).await?
            .ok_or("User not found")?;

        // 2. ドメインサービスによるビジネスルール検証
        TaskDomainService::can_user_create_task(&user)?;

        // 3. ドメインオブジェクトの作成(バリデーション含む)
        let task = Task::new(input.title, input.description)?;

        // 4. リポジトリを通じて保存
        let saved_task = self.task_repository.save(&task).await?;

        // 5. ビジネス要件に合わせた出力データの加工
        Ok(AddTaskOutput {
            id: saved_task.id.unwrap(),
            title: saved_task.title,
            description: saved_task.description,
            status: saved_task.status.to_string(),
            created_at: saved_task.created_at.unwrap().to_rfc3339(),
            priority: TaskDomainService::calculate_priority(&saved_task, &user),
        })
    }
}

#[derive(Debug)]
pub struct AddTaskInput {
    pub user_id: i32,
    pub title: String,
    pub description: String,
}

#[derive(Debug)]
pub struct AddTaskOutput {
    pub id: i32,
    pub title: String,
    pub description: String,
    pub status: String,
    pub created_at: String,
    pub priority: TaskPriority,
}

複雑なユースケース(データ加工)

// src/application/use_cases/task/list_tasks/list_tasks_use_case.rs
pub struct ListTasksUseCase {
    task_repository: Arc<dyn TaskRepository + Send + Sync>,
    user_repository: Arc<dyn UserRepository + Send + Sync>,
}

impl ListTasksUseCase {
    pub async fn execute(&self, input: ListTasksInput) -> Result<ListTasksOutput, Box<dyn Error + Send + Sync>> {
        // 1. ユーザーの存在確認
        let user = self.user_repository.find_by_id(input.user_id).await?
            .ok_or("User not found")?;

        // 2. データベースから生データを取得
        let raw_tasks = self.task_repository.find_by_user_id(input.user_id).await?;

        // 3. ビジネス要件に合わせたデータの加工・変換
        let processed_tasks = self.process_tasks_for_user(raw_tasks, &user).await?;

        // 4. フィルタリングとソート(ビジネスロジック)
        let filtered_tasks = self.apply_business_filters(processed_tasks, &input).await?;

        // 5. ページネーション処理
        let paginated_tasks = self.apply_pagination(filtered_tasks, input.page, input.page_size)?;

        // 6. 統計情報の計算
        let statistics = self.calculate_task_statistics(&raw_tasks, &user).await?;

        Ok(ListTasksOutput {
            tasks: paginated_tasks,
            statistics,
            pagination: PaginationInfo {
                current_page: input.page,
                total_pages: (raw_tasks.len() as f64 / input.page_size as f64).ceil() as u32,
                total_items: raw_tasks.len() as u32,
            },
        })
    }

    // データの加工処理
    async fn process_tasks_for_user(
        &self,
        tasks: Vec<Task>,
        user: &User,
    ) -> Result<Vec<ProcessedTask>, Box<dyn Error + Send + Sync>> {
        let mut processed_tasks = Vec::new();

        for task in tasks {
            // ビジネスルール:優先度の計算
            let priority = TaskDomainService::calculate_priority(&task, user);

            // ビジネスルール:期限の計算(VIPユーザーは期限が短い)
            let deadline = self.calculate_deadline(&task, user);

            // ビジネスルール:進捗率の計算
            let progress = self.calculate_progress(&task);

            // ビジネスルール:表示用のステータス変換
            let display_status = self.convert_status_for_display(&task, user);

            // ビジネスルール:機密情報のマスキング
            let masked_description = self.mask_sensitive_info(&task.description, user);

            processed_tasks.push(ProcessedTask {
                id: task.id.unwrap(),
                title: task.title,
                description: masked_description,
                status: task.status,
                display_status,
                priority,
                deadline,
                progress,
                created_at: task.created_at.unwrap(),
                is_editable: task.is_editable(),
                can_delete: self.can_user_delete_task(&task, user),
            });
        }

        Ok(processed_tasks)
    }

    // ビジネスフィルターの適用
    async fn apply_business_filters(
        &self,
        tasks: Vec<ProcessedTask>,
        input: &ListTasksInput,
    ) -> Result<Vec<ProcessedTask>, Box<dyn Error + Send + Sync>> {
        let mut filtered_tasks = tasks;

        // ビジネスルール:ステータスによるフィルタリング
        if let Some(status_filter) = &input.status_filter {
            filtered_tasks.retain(|task| task.status.to_string() == *status_filter);
        }

        // ビジネスルール:優先度によるフィルタリング
        if let Some(priority_filter) = &input.priority_filter {
            filtered_tasks.retain(|task| task.priority >= *priority_filter);
        }

        // ビジネスルール:期限によるフィルタリング
        if input.show_urgent_only {
            let now = Utc::now();
            filtered_tasks.retain(|task| {
                if let Some(deadline) = task.deadline {
                    deadline < now + Duration::days(3) // 3日以内のタスクのみ
                } else {
                    false
                }
            });
        }

        // ビジネスルール:ソート順の決定
        match input.sort_by.as_str() {
            "priority" => filtered_tasks.sort_by(|a, b| b.priority.cmp(&a.priority)),
            "deadline" => filtered_tasks.sort_by(|a, b| {
                a.deadline.unwrap_or(Utc::now() + Duration::days(365))
                    .cmp(&b.deadline.unwrap_or(Utc::now() + Duration::days(365)))
            }),
            "created_at" => filtered_tasks.sort_by(|a, b| b.created_at.cmp(&a.created_at)),
            _ => filtered_tasks.sort_by(|a, b| b.priority.cmp(&a.priority)),
        }

        Ok(filtered_tasks)
    }
}

#[derive(Debug)]
pub struct ProcessedTask {
    pub id: i32,
    pub title: String,
    pub description: String,
    pub status: TaskStatus,
    pub display_status: String,
    pub priority: TaskPriority,
    pub deadline: Option<DateTime<Utc>>,
    pub progress: f64,
    pub created_at: DateTime<Utc>,
    pub is_editable: bool,
    pub can_delete: bool,
}

依存性注入による疎結合

なぜ依存性注入が重要なのか?

初心者が陥りがちな問題:

// ❌ 悪い例:具体的な実装に直接依存
pub struct AddTaskUseCase {
    task_repository: TaskRepositoryImpl, // 具体的な実装に依存
}

impl AddTaskUseCase {
    pub fn new() -> Self {
        Self {
            task_repository: TaskRepositoryImpl::new(pool), // 直接インスタンス化
        }
    }
}

この問題点:

  1. テストが困難:実際のデータベースが必要
  2. 変更が困難:MySQLからPostgreSQLに変更する際、UseCaseのコードも変更が必要
  3. 責任の混在:UseCaseがデータベース接続の詳細を知っている
  4. 再利用性の低下:異なるデータベースでUseCaseを使い回せない

依存性注入による解決

// ✅ 良い例:インターフェースに依存
pub struct AddTaskUseCase {
    task_repository: Arc<dyn TaskRepository + Send + Sync>, // インターフェースに依存
}

impl AddTaskUseCase {
    pub fn new(task_repository: Arc<dyn TaskRepository + Send + Sync>) -> Self {
        Self { task_repository }
    }
}

依存性注入のメリット

1. テストの容易さ

// モックオブジェクトを使ったテスト
#[tokio::test]
async fn test_add_task_use_case() {
    // モックリポジトリを作成
    let mock_repository = Arc::new(MockTaskRepository::new());

    // テスト用のデータを設定
    mock_repository.expect_save()
        .times(1)
        .returning(|task| Ok(task.clone()));

    // UseCaseにモックを注入
    let use_case = AddTaskUseCase::new(mock_repository);

    let input = AddTaskInput {
        user_id: 1,
        title: "Test Task".to_string(),
        description: "Test Description".to_string(),
    };

    // 実際のデータベースなしでテスト可能
    let result = use_case.execute(input).await.unwrap();
    assert_eq!(result.title, "Test Task");
}

2. 技術スタックの変更が容易

// MySQLからPostgreSQLに変更する場合

// 変更前
let mysql_repository = Arc::new(MySqlTaskRepositoryImpl::new(mysql_pool));
let use_case = AddTaskUseCase::new(mysql_repository);

// 変更後(UseCaseのコードは一切変更不要)
let postgres_repository = Arc::new(PostgresTaskRepositoryImpl::new(postgres_pool));
let use_case = AddTaskUseCase::new(postgres_repository);

3. 責任の分離

// UseCaseはデータベースの詳細を知らない
impl AddTaskUseCase {
    pub async fn execute(&self, input: AddTaskInput) -> Result<AddTaskOutput, Box<dyn Error + Send + Sync>> {
        // ビジネスロジックのみに集中
        let task = Task::new(input.title, input.description)?;

        // データベースの詳細は知らない(インターフェースのみ使用)
        let saved_task = self.task_repository.save(&task).await?;

        Ok(AddTaskOutput::from(saved_task))
    }
}

実際のデータフロー

タスク作成の例で、データが各層をどのように流れるかを見てみましょう:

HTTP Request → Presentation → Application → Domain ← Infrastructure
     ↓              ↓            ↓           ↓           ↓
JSON Data    → Controller → UseCase → Entity ← Repository
     ↓              ↓            ↓           ↓           ↓
HTTP Response ← Handler ← Presenter ← Output ← Database

1. HTTPリクエストの受信

POST /tasks
{
    "title": "Test Task",
    "description": "Test Description"
}

2. Application層での処理

// HTTPリクエストをドメインオブジェクトに変換
let input = AddTaskInput {
    user_id: 1,
    title: request.title,
    description: request.description,
};

3. Domain層でのビジネスロジック

// ビジネスルールの検証とドメインオブジェクトの作成
let task = Task::new(input.title, input.description)?;

4. Infrastructure層での永続化

// データベースへの保存
let saved_task = self.task_repository.save(&task).await?;

5. レスポンスの生成

{
    "success": true,
    "data": {
        "id": 1,
        "title": "Test Task",
        "description": "Test Description",
        "status": "pending",
        "priority": 1
    }
}

テストの実装

Domain層のテスト

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

    #[test]
    fn test_task_creation_with_valid_data() {
        let task = Task::new(
            "Valid Task".to_string(),
            "Valid Description".to_string(),
        ).unwrap();

        assert_eq!(task.title, "Valid Task");
        assert_eq!(task.description, "Valid Description");
        assert_eq!(task.status, TaskStatus::Pending);
    }

    #[test]
    fn test_task_creation_with_empty_title() {
        let result = Task::new("".to_string(), "Description".to_string());
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().to_string(), "Task title cannot be empty");
    }

    #[test]
    fn test_task_status_transitions() {
        let mut task = Task::new("Test".to_string(), "Test".to_string()).unwrap();

        // Pending → InProgress
        assert!(task.start().is_ok());
        assert_eq!(task.status, TaskStatus::InProgress);

        // InProgress → Completed
        assert!(task.complete().is_ok());
        assert_eq!(task.status, TaskStatus::Completed);

        // Completed → Cancelled (should fail)
        assert!(task.cancel().is_err());
    }
}

Application層のテスト

#[cfg(test)]
mod tests {
    use super::*;
    use mockall::predicate::*;

    #[tokio::test]
    async fn test_add_task_use_case_success() {
        // モックの設定
        let mut mock_task_repo = MockTaskRepository::new();
        let mut mock_user_repo = MockUserRepository::new();

        // ユーザーのモック設定
        let user = User::new("testuser".to_string(), "password".to_string()).unwrap();
        mock_user_repo.expect_find_by_id()
            .with(eq(1))
            .times(1)
            .returning(move |_| Ok(Some(user.clone())));

        // タスクのモック設定
        let task = Task::new("Test Task".to_string(), "Test Description".to_string()).unwrap();
        let saved_task = Task {
            id: Some(1),
            ..task.clone()
        };
        mock_task_repo.expect_save()
            .times(1)
            .returning(move |_| Ok(saved_task.clone()));

        // UseCaseの実行
        let use_case = AddTaskUseCase::new(
            Arc::new(mock_task_repo),
            Arc::new(mock_user_repo),
        );

        let input = AddTaskInput {
            user_id: 1,
            title: "Test Task".to_string(),
            description: "Test Description".to_string(),
        };

        let result = use_case.execute(input).await.unwrap();
        assert_eq!(result.id, 1);
        assert_eq!(result.title, "Test Task");
    }
}

まとめ

この記事では、Domain層とApplication層の詳細な実装について学びました:

Domain層のポイント

  1. ビジネスルールの集約:すべてのビジネスロジックが一箇所に集約
  2. 外部依存なし:データベースやフレームワークに依存しない
  3. テストしやすい:外部システムなしで単体テスト可能

Application層のポイント

  1. ユースケースの実装:特定のビジネスシナリオの実行
  2. データの加工・変換:ビジネス要件に合わせたデータ処理
  3. 依存性注入:インターフェースに依存することで疎結合を実現

依存性注入の重要性

  1. テストの容易さ:モックオブジェクトで簡単にテスト可能
  2. 技術スタックの変更:実装を変更してもUseCaseは変更不要
  3. 責任の分離:各層が明確な責任を持つ

次回のPart3では、Infrastructure層とPresentation層の実装について詳しく学んでいきます。

コラボスタイル Developers

Discussion