📖
クリーンアーキテクチャ入門 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), // 直接インスタンス化
}
}
}
この問題点:
- テストが困難:実際のデータベースが必要
- 変更が困難:MySQLからPostgreSQLに変更する際、UseCaseのコードも変更が必要
- 責任の混在:UseCaseがデータベース接続の詳細を知っている
- 再利用性の低下:異なるデータベースで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層のポイント
- ビジネスルールの集約:すべてのビジネスロジックが一箇所に集約
- 外部依存なし:データベースやフレームワークに依存しない
- テストしやすい:外部システムなしで単体テスト可能
Application層のポイント
- ユースケースの実装:特定のビジネスシナリオの実行
- データの加工・変換:ビジネス要件に合わせたデータ処理
- 依存性注入:インターフェースに依存することで疎結合を実現
依存性注入の重要性
- テストの容易さ:モックオブジェクトで簡単にテスト可能
- 技術スタックの変更:実装を変更してもUseCaseは変更不要
- 責任の分離:各層が明確な責任を持つ
次回のPart3では、Infrastructure層とPresentation層の実装について詳しく学んでいきます。
Discussion