[Xcode/SwiftUI] Clean Architectureをざっくりと理解する

Clean Architectureとは

Clean Architecture = 「責務ごとにコードを層で分けて、依存関係を一方向に保つ」アーキテクチャ。
この構造を使うことで、

  • UIロジックとビジネスロジックが分離できる
  • APIやデータ構造が変わっても柔軟に対応できる
  • テストもしやすい

っていう便利なアーキテクチャに仕上がる。

構造の全体像(ざっくり4層)

各レイヤーの役割(カンタン説明)

レイヤー役割のざっくりイメージ
PresentationSwiftUIやViewModelで、ユーザーの操作を受け取る
Domainアプリの本質的な「やること」を定義(UseCaseなど)
Dataデータの取得・保存方法をまとめる(API, DBなど)
External実際の通信処理やストレージ(URLSessionなど)

依存関係のルール(重要)

  • 上の層は下の層に依存しない = ViewModelUseCase を知ってるけど、UseCaseViewModel のことを一切知らない。
  • 抽象プロトコル(interface)で依存方向を逆転させる

Clean Architectureで考えるコツ

  1. 「誰が何をしたいのか」からスタート(ユースケースを言語化)
  2. それをどう達成するか?」を層ごとに分解
  3. SwiftUIのコード量を減らして、責任を分散

簡単な天気アプリでざっくり構成と各レイヤの役割を理解する

ファイル構成

  • WeatherView.swift
  • WeatherViewModel.swift
  • FetchWeatherUseCase.swift
  • WeatherRepository.swift
  • WeatherAPIClient.swift
  • Weather.swift

Clean Architecture構成図

ファイル名レイヤー役割例え (ポケモン)
WeatherView.swiftPresentationUI表示。ユーザーの入力をViewModelに伝えるバトル画面
WeatherViewModel.swiftPresentationViewの状態管理。UseCaseを呼び出しUI更新ポケモンのトレーナー
FetchWeatherUseCase.swiftDomainビジネスロジックの中心。Repositoryを使ってデータ取得「たいあたり」を出す命令
WeatherRepository.swiftDataデータの中継役。APIClientから取得しUseCaseへ渡すポケモン図鑑
WeatherAPIClient.swiftData (Low-Level)実際にAPIへ通信してデータを取得する通信機器
Weather.swiftEntity/ModelAPIのレスポンスを構造化(DTO)ポケモンのステータス

実装

import Foundation

struct Weather: Identifiable {
    let id = UUID()
    let cityName: String
    let temperature: Double
    let description: String
}
import SwiftUI

@MainActor
final class WeatherViewModel: ObservableObject {
    @Published var city: String = ""
    @Published var weather: Weather?
    @Published var errorMessage: String?
    @Published var isLoading: Bool = false

    private let fetchWeatherUseCase: FetchWeatherUseCase

    // DI経由で UseCase を注入
    init(fetchWeatherUseCase: FetchWeatherUseCase) {
        self.fetchWeatherUseCase = fetchWeatherUseCase
    }

    func fetchWeather() {
        isLoading = true
        Task {
            do {
                // UseCase経由で非同期に天気情報を取得
                let result = try await fetchWeatherUseCase.execute(city: city)
                weather = result
                errorMessage = nil
                isLoading = false
            } catch (let error) {
                weather = nil
                errorMessage = error.localizedDescription
                isLoading = false
            }
        }
    }
}
import Foundation

// Presentation層から呼び出され、ビジネスロジックを提供
protocol FetchWeatherUseCase {
    func execute(city: String) async throws -> Weather
}

// Repositoryを通じてデータ取得処理を実行
final class FetchWeatherUseCaseImpl: FetchWeatherUseCase {
    private let repository: WeatherRepository

    // DIによって Repository を注入
    init(repository: WeatherRepository) {
        self.repository = repository
    }

    // ユースケースの実行処理(都市名を指定して天気を取得)
    func execute(city: String) async throws -> Weather {
        try await repository.fetchWeather(for: city)
    }
}
import Foundation

// Domain層やUseCaseはこのプロトコルに依存する
protocol WeatherRepository {
    func fetchWeather(for city: String) async throws -> Weather
}

// APIClientからDTOを取得し、アプリ用のEntityに変換して返す
final class WeatherRepositoryImpl: WeatherRepository {
    private let apiClient: WeatherAPIClient

    // DIによりAPIクライアントを注入
    init(apiClient: WeatherAPIClient) {
        self.apiClient = apiClient
    }

    // DTO(APIレスポンス) → Entity(アプリ内モデル)への変換
    func fetchWeather(for city: String) async throws -> Weather {
        let dto = try await apiClient.fetchWeather(for: city)
        return Weather(
            cityName: dto.name,
            temperature: dto.main.temp,
            description: dto.weather.first?.description ?? "No description"
        )
    }
}
import Foundation

// OpenWeather APIのレスポンスをマッピングするDTO
// アプリ内部ではこのまま使わず、RepositoryでEntityへ変換する
struct WeatherDTO: Decodable {
    struct Main: Decodable {
        let temp: Double
    }

    struct WeatherInfo: Decodable {
        let description: String
    }

    let name: String
    let main: Main
    let weather: [WeatherInfo]
}

// OpenWeather APIに通信して、WeatherDTOを取得するクラス(Data Layer)
final class WeatherAPIClient {
    private let apiKey = "<#OpenWeatherAPIKey#>"

    func fetchWeather(for city: String) async throws -> WeatherDTO {
        let urlString = "https://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=\(apiKey)&units=metric"
        guard let url = URL(string: urlString) else {
            throw URLError(.badURL)
        }
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(WeatherDTO.self, from: data)
    }
}
import SwiftUI

struct WeatherView: View {
    @StateObject var viewModel: WeatherViewModel

    var body: some View {
        NavigationView {
            VStack(spacing: 16) {
                // 🔍 検索
                HStack {
                    TextField("都市名を入力", text: $viewModel.city)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .padding(.leading)

                    Button(action: {
                        viewModel.fetchWeather()
                    }) {
                        Image(systemName: "magnifyingglass")
                            .padding()
                            .background(Color.blue)
                            .foregroundColor(.white)
                            .clipShape(Circle())
                    }
                }
                .padding(.horizontal)

                // 🔄 ローディング
                if viewModel.isLoading {
                    ProgressView("取得中...")
                        .progressViewStyle(CircularProgressViewStyle())
                        .padding()
                }

                // 🌤 天気表示
                if let weather = viewModel.weather {
                    WeatherCardView(weather: weather)
                        .transition(.opacity.combined(with: .move(edge: .top)))
                        .animation(.spring(), value: weather.cityName)
                }

                // ❗️エラー
                if let error = viewModel.errorMessage {
                    Text(error)
                        .foregroundColor(.red)
                        .padding(.top, 8)
                }

                Spacer()
            }
            .navigationTitle("🌦天気アプリ")
            .padding()
        }
    }
}

// MARK: - WeatherCardView
struct WeatherCardView: View {
    let weather: Weather

    var body: some View {
        VStack(spacing: 12) {
            Image(systemName: iconFor(description: weather.description))
                .resizable()
                .scaledToFit()
                .frame(width: 60, height: 60)
                .foregroundColor(.yellow)

            Text(weather.cityName)
                .font(.title)
                .bold()

            Text("\(weather.temperature, specifier: "%.1f")℃")
                .font(.system(size: 36, weight: .semibold))

            Text(weather.description.capitalized)
                .font(.subheadline)
                .foregroundColor(.gray)
        }
        .padding()
        .frame(maxWidth: .infinity)
        .background(Color.white)
        .cornerRadius(20)
        .shadow(color: .gray.opacity(0.3), radius: 10, x: 0, y: 4)
        .padding(.horizontal)
    }

    private func iconFor(description: String) -> String {
        switch description.lowercased() {
        case let desc where desc.contains("cloud"):
            return "cloud.fill"
        case let desc where desc.contains("rain"):
            return "cloud.rain.fill"
        case let desc where desc.contains("sun"):
            return "sun.max.fill"
        case let desc where desc.contains("clear"):
            return "sun.max.fill"
        default:
            return "cloud.sun.fill"
        }
    }
}

アプリのエントリーポイントは以下のように

import SwiftUI

@main
struct <#プロジェクト名#>App: App {
    var body: some Scene {
        WindowGroup {
            let apiClient = WeatherAPIClient()
            let respository = WeatherRepositoryImpl(apiClient: apiClient)
            let useCase = FetchWeatherUseCaseImpl(repository: respository)
            let viewModel = WeatherViewModel(fetchWeatherUseCase: useCase)
            WeatherView(viewModel: viewModel)
    }
}