📖
クリーンアーキテクチャ入門 Part 3: 外部システムとの連携(Infrastructure層・Presentation層)
クリーンアーキテクチャ入門 Part 3: 外部システムとの連携(Infrastructure層・Presentation層)
はじめに
Part2でビジネスロジックの設計について学んだら、次は外部システムとの連携について詳しく学んでいきます。この記事では、Infrastructure層とPresentation層の実装に焦点を当て、データベース、外部API、HTTPレスポンスの処理について理解を深めていきます。
この記事で学べること:
- Infrastructure層での外部システムとの連携
- Presentation層でのHTTPリクエスト・レスポンスの処理
- 技術的詳細の隠蔽方法
- 外部APIとの連携例
Infrastructure層の詳細実装
Infrastructure層は、外部システムとの連携を担当し、技術的詳細を他の層に隠蔽します。
なぜ技術的詳細を隠蔽するのか?
- 他の層への影響を防ぐ:データベースの変更や外部APIの仕様変更が、ビジネスロジックに影響しない
- テストの容易さ:外部システムなしでビジネスロジックをテストできる
-
技術スタックの変更:MySQLからPostgreSQL、REST
APIからGraphQLへの変更が容易 - 障害の局所化:外部システムの障害が他の層に波及しない
どの層にデータを返すのか?
- Application層:リポジトリパターンを通じて、ドメインオブジェクトを返す
- Domain層:インターフェースの契約に従って、純粋なドメインオブジェクトを返す
- 外部システムの詳細は隠蔽:HTTPレスポンス、SQL結果、ファイルパスなどの技術的詳細は返さない
データベースリポジトリの実装
// src/infrastructure/repositories/task_repository_impl.rs
use async_trait::async_trait;
use sqlx::MySqlPool;
use crate::domain::entities::Task;
use crate::domain::repositories::TaskRepository;
pub struct TaskRepositoryImpl {
pool: MySqlPool,
}
impl TaskRepositoryImpl {
pub fn new(pool: MySqlPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl TaskRepository for TaskRepositoryImpl {
async fn save(&self, task: &Task) -> Result<Task, Box<dyn Error + Send + Sync>> {
// 技術的詳細:SQLクエリの実行
let query = "INSERT INTO tasks (title, description, created_at, status) VALUES (?, ?, ?, ?)";
let result = sqlx::query(query)
.bind(&task.title)
.bind(&task.description)
.bind(task.created_at)
.bind(task.status.to_string())
.execute(&self.pool)
.await?;
let id = result.last_insert_id() as i32;
// ドメインオブジェクトとして返す(技術的詳細は隠蔽)
Ok(Task {
id: Some(id),
title: task.title.clone(),
description: task.description.clone(),
created_at: task.created_at,
status: task.status.clone(),
})
}
async fn find_by_id(&self, id: i32) -> Result<Option<Task>, Box<dyn Error + Send + Sync>> {
// 技術的詳細:SQLクエリの実行
let row = sqlx::query_as!(
TaskRow,
"SELECT id, title, description, created_at, status FROM tasks WHERE id = ?",
id
)
.fetch_optional(&self.pool)
.await?;
// データベースの行データをドメインオブジェクトに変換
match row {
Some(row) => Ok(Some(Task {
id: Some(row.id),
title: row.title,
description: row.description,
created_at: Some(row.created_at),
status: TaskStatus::from_string(&row.status)?,
})),
None => Ok(None),
}
}
}
// データベース固有の構造体(技術的詳細)
#[derive(sqlx::FromRow)]
struct TaskRow {
id: i32,
title: String,
description: String,
created_at: DateTime<Utc>,
status: String,
}
外部APIとの連携
// src/infrastructure/external_apis/notification_service.rs
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::domain::entities::Task;
use crate::domain::services::NotificationService;
pub struct ExternalNotificationServiceImpl {
client: Client,
api_base_url: String,
api_key: String,
}
impl ExternalNotificationServiceImpl {
pub fn new(api_base_url: String, api_key: String) -> Self {
Self {
client: Client::new(),
api_base_url,
api_key,
}
}
}
#[async_trait]
impl NotificationService for ExternalNotificationServiceImpl {
async fn send_task_notification(
&self,
task: &Task,
user_email: &str,
) -> Result<NotificationResult, Box<dyn Error + Send + Sync>> {
// 技術的詳細:外部APIへのHTTPリクエスト
let notification_data = NotificationRequest {
to: user_email.to_string(),
subject: format!("Task Update: {}", task.title),
message: self.format_notification_message(task),
priority: self.calculate_notification_priority(task),
};
let response = self.client
.post(&format!("{}/notifications", self.api_base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(¬ification_data)
.send()
.await?;
// 外部APIのレスポンスを処理
if response.status().is_success() {
let api_response: NotificationResponse = response.json().await?;
// 外部APIの詳細は隠蔽し、ドメイン層に必要な情報のみを返す
if api_response.success {
Ok(NotificationResult::Success {
task_id: task.id.unwrap(),
email: user_email.to_string(),
notification_id: api_response.notification_id,
sent_at: Utc::now(),
})
} else {
Ok(NotificationResult::Failure {
task_id: task.id.unwrap(),
email: user_email.to_string(),
error: api_response.error.unwrap_or_else(|| "Unknown error".to_string()),
sent_at: Utc::now(),
})
}
} else {
Ok(NotificationResult::Failure {
task_id: task.id.unwrap(),
email: user_email.to_string(),
error: format!("HTTP error: {}", response.status()),
sent_at: Utc::now(),
})
}
}
}
// 外部API固有の構造体(技術的詳細)
#[derive(Serialize)]
struct NotificationRequest {
to: String,
subject: String,
message: String,
priority: String,
}
#[derive(Deserialize)]
struct NotificationResponse {
success: bool,
error: Option<String>,
notification_id: Option<String>,
// 外部API固有のフィールド(技術的詳細)
api_version: Option<String>,
rate_limit_remaining: Option<i32>,
response_time_ms: Option<u64>,
}
// ドメイン層に返す構造体(技術的詳細は隠蔽)
#[derive(Debug)]
pub struct NotificationResult {
pub task_id: i32,
pub email: String,
pub notification_id: Option<String>, // 外部APIから取得したID
pub sent_at: DateTime<Utc>, // 送信時刻
pub success: bool,
pub error: Option<String>,
}
外部APIからのデータ取得例
// src/infrastructure/external_apis/weather_service.rs
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::domain::entities::WeatherInfo;
use crate::domain::services::WeatherService;
pub struct ExternalWeatherServiceImpl {
client: Client,
api_base_url: String,
api_key: String,
}
#[async_trait]
impl WeatherService for ExternalWeatherServiceImpl {
async fn get_weather_info(
&self,
location: &str,
) -> Result<WeatherInfo, Box<dyn Error + Send + Sync>> {
// 技術的詳細:外部APIへのHTTPリクエスト
let response = self.client
.get(&format!("{}/weather", self.api_base_url))
.query(&[
("q", location),
("appid", &self.api_key),
("units", &"metric".to_string()),
])
.send()
.await?;
// 外部APIのレスポンスを処理
if response.status().is_success() {
let api_response: WeatherApiResponse = response.json().await?;
// 外部APIのデータをドメインオブジェクトに変換
Ok(WeatherInfo {
location: api_response.name,
temperature: api_response.main.temp,
humidity: api_response.main.humidity,
description: api_response.weather.first()
.map(|w| w.description.clone())
.unwrap_or_default(),
wind_speed: api_response.wind.speed,
// 外部API固有のフィールドは隠蔽
// api_response.sys.country, api_response.coord.lat などは使用しない
})
} else {
Err(format!("Weather API error: {}", response.status()).into())
}
}
}
// 外部API固有の構造体(技術的詳細)
#[derive(Deserialize)]
struct WeatherApiResponse {
name: String,
main: MainWeatherData,
weather: Vec<WeatherDescription>,
wind: WindData,
sys: SystemData,
coord: Coordinates,
// 外部API固有のフィールド
dt: i64,
timezone: i32,
id: i32,
}
#[derive(Deserialize)]
struct MainWeatherData {
temp: f64,
humidity: i32,
pressure: i32,
temp_min: f64,
temp_max: f64,
}
#[derive(Deserialize)]
struct WeatherDescription {
description: String,
icon: String,
id: i32,
main: String,
}
#[derive(Deserialize)]
struct WindData {
speed: f64,
deg: i32,
}
#[derive(Deserialize)]
struct SystemData {
country: String,
sunrise: i64,
sunset: i64,
}
#[derive(Deserialize)]
struct Coordinates {
lat: f64,
lon: f64,
}
// ドメイン層に返す構造体(技術的詳細は隠蔽)
#[derive(Debug, Clone)]
pub struct WeatherInfo {
pub location: String,
pub temperature: f64,
pub humidity: i32,
pub description: String,
pub wind_speed: f64,
// 外部API固有のフィールドは含まない
// 例:api_version, response_time_ms, rate_limit_remaining など
}
ファイルシステムとの連携
// src/infrastructure/file_system/task_export_service.rs
use async_trait::async_trait;
use std::path::PathBuf;
use tokio::fs;
use crate::domain::entities::Task;
use crate::domain::services::TaskExportService;
pub struct FileSystemTaskExportServiceImpl {
export_directory: PathBuf,
}
impl FileSystemTaskExportServiceImpl {
pub fn new(export_directory: PathBuf) -> Self {
Self { export_directory }
}
}
#[async_trait]
impl TaskExportService for FileSystemTaskExportServiceImpl {
async fn export_tasks_to_csv(
&self,
tasks: &[Task],
filename: &str,
) -> Result<ExportResult, Box<dyn Error + Send + Sync>> {
// 技術的詳細:CSVファイルの生成
let mut csv_content = String::new();
csv_content.push_str("ID,Title,Description,Status,Created At\n");
for task in tasks {
csv_content.push_str(&format!(
"{},{},{},{},{}\n",
task.id.unwrap_or(0),
escape_csv_field(&task.title),
escape_csv_field(&task.description),
task.status,
task.created_at
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_default(),
));
}
// 技術的詳細:ファイルの書き込み
let file_path = self.export_directory.join(filename);
fs::write(&file_path, csv_content).await?;
// ドメイン層に必要な情報のみを返す(ファイルパスは隠蔽)
Ok(ExportResult {
success: true,
exported_count: tasks.len(),
file_size: fs::metadata(&file_path).await?.len() as usize,
})
}
}
// ドメイン層に返す構造体(技術的詳細は隠蔽)
#[derive(Debug)]
pub struct ExportResult {
pub success: bool,
pub exported_count: usize,
pub file_size: usize,
}
fn escape_csv_field(field: &str) -> String {
if field.contains(',') || field.contains('"') || field.contains('\n') {
format!("\"{}\"", field.replace("\"", "\"\""))
} else {
field.to_string()
}
}
Presentation層の詳細実装
Presentation層は、ユーザーインターフェースを担当し、HTTPリクエスト・レスポンスの処理を行います。
なぜコントローラーからの返却データを扱うのか?
- ビジネスロジックの結果をHTTP形式に変換:ドメインオブジェクトをJSONレスポンスに変換
- HTTPステータスコードの決定:ビジネスロジックの成功・失敗を適切なHTTPステータスに変換
- エラーメッセージの整形:ビジネスエラーをユーザーに分かりやすい形式で返す
- レスポンスヘッダーの設定:Content-Type、Location、Cache-Controlなどの設定
コントローラーの実装
// src/presentation/controllers/task_controller.rs
use async_trait::async_trait;
use std::sync::Arc;
use crate::application::use_cases::task::add_task::AddTaskUseCase;
use crate::presentation::view_models::task::TaskResponse;
pub struct TaskControllerImpl {
add_task_use_case: Arc<AddTaskUseCase>,
}
impl TaskControllerImpl {
pub fn new(add_task_use_case: Arc<AddTaskUseCase>) -> Self {
Self { add_task_use_case }
}
pub async fn add_task(&self, request: AddTaskHttpRequest) -> Result<TaskResponse, Box<dyn Error + Send + Sync>> {
// HTTPリクエストをドメインオブジェクトに変換
let input = AddTaskInput {
user_id: request.user_id,
title: request.title,
description: request.description,
};
// ユースケースを実行(Application層の呼び出し)
let output = self.add_task_use_case.execute(input).await?;
// コントローラーからの返却データをHTTPレスポンス形式に変換
Ok(TaskResponse {
success: true,
data: Some(TaskData {
id: output.id,
title: output.title,
description: output.description,
status: output.status,
created_at: output.created_at,
priority: output.priority.0,
}),
error: None,
timestamp: Utc::now().to_rfc3339(),
})
}
pub async fn get_task(&self, task_id: i32) -> Result<TaskResponse, Box<dyn Error + Send + Sync>> {
// ユースケースを実行
let output = self.get_task_use_case.execute(task_id).await?;
// コントローラーからの返却データを処理
match output {
Some(task) => Ok(TaskResponse {
success: true,
data: Some(TaskData::from(task)),
error: None,
timestamp: Utc::now().to_rfc3339(),
}),
None => Ok(TaskResponse {
success: false,
data: None,
error: Some("Task not found".to_string()),
timestamp: Utc::now().to_rfc3339(),
}),
}
}
}
#[derive(Debug, Deserialize)]
pub struct AddTaskHttpRequest {
pub user_id: i32,
pub title: String,
pub description: String,
}
HTTPハンドラーでのコントローラー返却データの処理
// src/presentation/handlers/task_handlers.rs
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::Json,
};
use std::sync::Arc;
use crate::TaskAppState;
pub async fn add_task(
State(state): State<Arc<TaskAppState>>,
Json(request): Json<AddTaskHttpRequest>,
) -> impl axum::response::IntoResponse {
// コントローラーを呼び出し
match state.task_controller.add_task(request).await {
Ok(response) => {
// コントローラーからの返却データをHTTPレスポンスに変換
let status_code = if response.success {
StatusCode::CREATED
} else {
StatusCode::BAD_REQUEST
};
// レスポンスヘッダーの設定
let headers = [
("Content-Type", "application/json"),
("Location", &format!("/tasks/{}", response.data.as_ref().unwrap().id)),
];
(status_code, headers, Json(response))
}
Err(error) => {
// エラーレスポンスの生成
let error_response = ErrorResponse {
success: false,
error: error.to_string(),
timestamp: Utc::now().to_rfc3339(),
};
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
}
}
}
pub async fn get_task(
State(state): State<Arc<TaskAppState>>,
Path(task_id): Path<i32>,
) -> impl axum::response::IntoResponse {
// コントローラーを呼び出し
match state.task_controller.get_task(task_id).await {
Ok(response) => {
// コントローラーからの返却データを処理
if response.success {
// 成功時:200 OK
(StatusCode::OK, Json(response))
} else {
// ビジネスエラー時:404 Not Found
(StatusCode::NOT_FOUND, Json(response))
}
}
Err(error) => {
// システムエラー時:500 Internal Server Error
let error_response = ErrorResponse {
success: false,
error: error.to_string(),
timestamp: Utc::now().to_rfc3339(),
};
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
}
}
}
pub async fn list_tasks(
State(state): State<Arc<TaskAppState>>,
Query(params): Query<ListTasksQuery>,
) -> impl axum::response::IntoResponse {
// クエリパラメータをドメインオブジェクトに変換
let input = ListTasksInput {
user_id: params.user_id,
page: params.page.unwrap_or(1),
page_size: params.page_size.unwrap_or(10),
status_filter: params.status,
sort_by: params.sort_by.unwrap_or_else(|| "priority".to_string()),
};
// コントローラーを呼び出し
match state.task_controller.list_tasks(input).await {
Ok(response) => {
// コントローラーからの返却データをHTTPレスポンスに変換
let status_code = if response.success {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
// ページネーション情報をヘッダーに追加
let headers = [
("X-Total-Count", &response.pagination.total_items.to_string()),
("X-Total-Pages", &response.pagination.total_pages.to_string()),
("X-Current-Page", &response.pagination.current_page.to_string()),
];
(status_code, headers, Json(response))
}
Err(error) => {
let error_response = ErrorResponse {
success: false,
error: error.to_string(),
timestamp: Utc::now().to_rfc3339(),
};
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
}
}
}
レスポンス構造体の定義
// src/presentation/view_models/task/task_response.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct TaskResponse {
pub success: bool,
pub data: Option<TaskData>,
pub error: Option<String>,
// HTTPレスポンス固有の情報
pub timestamp: String,
pub request_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct TaskData {
pub id: i32,
pub title: String,
pub description: String,
pub status: String,
pub created_at: String,
pub priority: u32,
// ビジネスロジックの結果をHTTP形式に変換
pub is_editable: bool,
pub can_delete: bool,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub success: bool,
pub error: String,
pub timestamp: String,
// エラー情報の詳細
pub error_code: Option<String>,
pub details: Option<Vec<String>>,
}
// クエリパラメータの構造体
#[derive(Debug, Deserialize)]
pub struct ListTasksQuery {
pub user_id: i32,
pub page: Option<u32>,
pub page_size: Option<u32>,
pub status: Option<String>,
pub sort_by: Option<String>,
}
プレゼンターでのデータ変換
// src/presentation/presenters/task_presenter.rs
use crate::domain::entities::Task;
use crate::presentation::view_models::task::{TaskData, ErrorResponse};
pub struct TaskPresenter;
impl TaskPresenter {
// コントローラーからの返却データをHTTPレスポンス形式に変換
pub fn present_task(&self, task: Task) -> TaskData {
TaskData {
id: task.id.unwrap(),
title: task.title,
description: task.description,
status: task.status.to_string(),
created_at: task.created_at
.map(|dt| dt.to_rfc3339())
.unwrap_or_default(),
priority: 1, // デフォルト優先度
is_editable: task.is_editable(),
can_delete: task.status == TaskStatus::Pending,
}
}
// 複数のタスクをHTTPレスポンス形式に変換
pub fn present_tasks(&self, tasks: Vec<Task>) -> Vec<TaskData> {
tasks.into_iter().map(|task| self.present_task(task)).collect()
}
// エラーをHTTPレスポンス形式に変換
pub fn present_error(&self, error: &str) -> ErrorResponse {
ErrorResponse {
success: false,
error: error.to_string(),
timestamp: Utc::now().to_rfc3339(),
error_code: self.extract_error_code(error),
details: self.extract_error_details(error),
}
}
// エラーコードの抽出
fn extract_error_code(&self, error: &str) -> Option<String> {
if error.contains("not found") {
Some("TASK_NOT_FOUND".to_string())
} else if error.contains("validation") {
Some("VALIDATION_ERROR".to_string())
} else {
Some("INTERNAL_ERROR".to_string())
}
}
// エラー詳細の抽出
fn extract_error_details(&self, error: &str) -> Option<Vec<String>> {
if error.contains("title") && error.contains("empty") {
Some(vec!["Task title cannot be empty".to_string()])
} else {
None
}
}
}
Application層での外部APIデータの活用例
// src/application/use_cases/task/add_task_with_weather/add_task_with_weather_use_case.rs
use std::sync::Arc;
use crate::domain::entities::Task;
use crate::domain::repositories::TaskRepository;
use crate::domain::services::WeatherService;
pub struct AddTaskWithWeatherUseCase {
task_repository: Arc<dyn TaskRepository + Send + Sync>,
weather_service: Arc<dyn WeatherService + Send + Sync>,
}
impl AddTaskWithWeatherUseCase {
pub fn new(
task_repository: Arc<dyn TaskRepository + Send + Sync>,
weather_service: Arc<dyn WeatherService + Send + Sync>,
) -> Self {
Self {
task_repository,
weather_service,
}
}
pub async fn execute(&self, input: AddTaskWithWeatherInput) -> Result<AddTaskWithWeatherOutput, Box<dyn Error + Send + Sync>> {
// 1. 外部APIから天気情報を取得
let weather_info = self.weather_service.get_weather_info(&input.location).await?;
// 2. 天気情報に基づいてタスクの優先度を調整
let adjusted_priority = self.adjust_priority_by_weather(&input, &weather_info);
// 3. タスクを作成
let task = Task::new(input.title, input.description)?;
// 4. リポジトリに保存
let saved_task = self.task_repository.save(&task).await?;
// 5. ドメイン層に必要な情報のみを返す
Ok(AddTaskWithWeatherOutput {
task: saved_task,
weather_info,
adjusted_priority,
// 外部API固有の情報は含まない
// 例:api_response_time, rate_limit_remaining など
})
}
fn adjust_priority_by_weather(&self, input: &AddTaskWithWeatherInput, weather: &WeatherInfo) -> TaskPriority {
let base_priority = TaskPriority(1);
// 天気に基づく優先度調整(ビジネスロジック)
let weather_multiplier = match weather.description.to_lowercase().as_str() {
"rain" | "storm" => 2, // 雨の日は優先度を上げる
"sunny" | "clear" => 1, // 晴れの日は通常
_ => 1,
};
TaskPriority(base_priority.0 * weather_multiplier)
}
}
// Application層で使用する入力・出力構造体
#[derive(Debug)]
pub struct AddTaskWithWeatherInput {
pub title: String,
pub description: String,
pub location: String,
}
#[derive(Debug)]
pub struct AddTaskWithWeatherOutput {
pub task: Task,
pub weather_info: WeatherInfo,
pub adjusted_priority: TaskPriority,
// 外部API固有の情報は含まない
}
まとめ
この記事では、Infrastructure層とPresentation層の詳細な実装について学びました:
Infrastructure層のポイント
- 技術的詳細の隠蔽:SQL、HTTP、ファイルI/Oの詳細を他の層に漏らさない
- 外部システムの抽象化:外部APIの仕様変更や障害を他の層に影響させない
- データ変換の責任:外部システムのデータ形式をドメインオブジェクトに変換
- 障害の局所化:外部システムの障害がビジネスロジックに波及しない
Presentation層のポイント
- コントローラーからの返却データの処理:ビジネスロジックの結果をHTTP形式に変換
- HTTPステータスコードの決定:ビジネスロジックの成功・失敗を適切なHTTPステータスに変換
- レスポンスヘッダーの設定:HTTPプロトコル固有の情報を設定
- エラーハンドリングの統一:ビジネスエラーを一貫したHTTPレスポンス形式に変換
外部システムとの連携の重要性
- 技術的詳細の完全隠蔽:外部システムの詳細を他の層に漏らさない
- データ変換の責任:外部システムのデータ形式をドメインオブジェクトに変換
- 障害の局所化:外部システムの障害がビジネスロジックに波及しない
- 交換可能性:技術スタックの変更が他の層に影響しない
次回のPart 4では、実践的な開発フローとAI活用について詳しく学んでいきます。
Discussion