📝

【SwiftUI】MVVM + Clean ArchitectureでToDoアプリ作ってみた

2024/10/15に公開

はじめに

今回はSwiftUIとClean Architectureに焦点を当て、アーキテクチャについて学んだことを記事にまとめています。

インプットである書籍や記事をベースに、Domain層・Infrastructure層・UseCase層・Presentation層の順にボトムアップで実装する過程を言語化しております。

今回のプロジェクトのテストコードもユニットテストレベルですが、限られた時間で書いてみました。

記事の構成

  1. 【設計】ToDoアプリの構成とドメインモデルの設計
    • 全体の構成
    • モデルの設計
  2. 【実装】Clean Architectureをベースにボトムアップで実装
    • Domain層の実装
    • Infrastructure層の実装
    • UseCase層の実装
    • Presentation層の実装
  3. まとめ
  4. 参考

コードについて

コードをGitHubに公開しています。
中身を見たい方は以下のリンクからチェックしてみてください。
また、コードに関するフィードバックも大歓迎です。

GitHubリポジトリへのリンク

https://github.com/Yoppei/swiftui-mvvm-clean-architecture-todo

環境

  • Xcode 16.0
  • Swift 6.0

外部ライブラリ

Dependency Injection用にSwinjectを採用してます。

今回のテーマ: ToDoアプリ

ToDoアプリをテーマとしました。

今回取り挙げるテーマから以下を学習していきます。

  1. CRUD処理を一通りUseCase層で実装し、Presentation層とUseCase層の処理の流れを理解する
  2. シンプルな要件でドメイン知識をDomain層に表現する
  3. 要件を満たすコードをユニットテストを書きながら組み立てる

本記事では、1, 2を主に取り上げ、各層の構成をコードを交えながら言語化していきます。

機能(ユースケース)

今回のToDoアプリは以下をスコープとしています。

  • タスクの作成
  • タスクの一覧取得
  • タスクの詳細閲覧
  • タスクの更新
  • タスクの削除

また、今回のモデリングも上記をユースケースとしています。

ドメイン知識(ルール/制約)

タスクに対するドメインのルール・制約を以下のように設定しています。

  • タイトルの文字列の前後に空白がある場合、空白は削除される
  • タイトルの文字数は前後の空白を除いて、1文字以上20文字以内である
  • ノートの文字列の前後に空白がある場合、空白は削除される
  • 予定日を設定時は今日以降とする

制作イメージ

1. 【設計】ToDoアプリの構成とドメインモデルの設計

このセクションでは、主に全体の構成とDomain層の設計について触れていきます。

全体の構成図

Clean Architectureをベースに、大きく4つのレイヤーに責務を分けています。

  • Domain
  • Infrastructure
  • UseCase
  • Presentation

また、今回のテーマではPresentation層をMVVMで構成しています。

上記から今回のプロジェクトに適用した構成図を以下に示します。

Protocolは、Presentation層とUseCase層の間のインターフェースとしてTaskUseCaseProtocolを、UseCase層とInfrastructure層の間のインターフェースとしてTaskRepositoryProtocolを設置しています。

Domain層の設計: ドメインモデル図

さらにDomain層にフォーカスして、ドメインモデル図を作成してみます。
ドメインモデル図は先述の機能(ユースケース)とドメイン知識(ルール/制約)をベースに作成していきます。

以下のドメインモデル図では、TaskEntityをドメインオブジェクトのエンティティ、TaskTitleValueObject, TaskNoteValueObject, TaskDueDateValueObjectを値オブジェクトとしています。
また、ドメインオブジェクトにメモ形式でルールと制約を追記しています。

値オブジェクトとして切り出している理由は、以下3点です。

  • ユーザーの入力値になるプロパティはルール/制約が肥大化しTaskEntityの凝集度が下がる
  • TaskEntityよりもスコープの狭い責務として切り出すことで管理しやすくなる
  • ユニットテストの粒度を細分化できる

ドメインモデル図の設計が終われば、いよいよ実装です。
次のセクションでは、ドメイン知識であるルール/制約をドメインオブジェクトに落とし込む作業から始めていきます。
その後、Domain層→Infrastructure層→UseCase層→Presentation層の順でボトムアップで組み立てていきます。

2. 【実装】Clean Architectureをベースにボトムアップで実装

ここからはDomain, Infrastructure, UseCase, Viewの4部構成でボトムアップで実装していきます。
要点とそれに対応するコードのみを取り上げて、進めていきます。
まずは、Domain層のドメインオブジェクトの実装です。

Domain層の実装

以下のコードは、TaskTitleValueObjectを例に挙げて、ドメイン知識を値オブジェクトに落とし込んでいます。

TaskTitleValueObject.swift
struct TaskTitleValueObject {
    
    private(set) var value: String
    
    init(_ title: String) throws {
        let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
        
        guard trimmedTitle.count <= 20 else {
            throw Error.titleTooLong
        }
        
        guard trimmedTitle.count >= 1 else {
            throw Error.titleTooShort
        }
        
        self.value = trimmedTitle
    }
    
}

具体的に、init内に以下のドメイン知識を実現しています。

  • trimmingCharacters(in:)による文頭・文末の空白削除
  • guard文による文字数チェック

ドメインのルールに満たさない場合は、エラーを投げています。
ドメインのルール違反はViewに反映させる必要があるため、基本的には関数でthrowingします。

[補足] エラーの定義
TaskTitleValueObject.swift
extension TaskTitleValueObject {
    
    enum Error: LocalizedError {
        case titleTooLong
        case titleTooShort
        
        var errorDescription: String? {
            switch self {
            case .titleTooLong: "タイトルが長すぎます。"
            case .titleTooShort: "タイトルが短すぎます。"
            }
        }
    }
    
}

エンティティTaskEntityも一部載せておきます。

TaskEntity.swift
final class TaskEntity: Identifiable, ObservableObject {
    
    private(set) var id: UUID
    private(set) var title: TaskTitleValueObject
    private(set) var note: TaskNoteValueObject
    private(set) var dueDate: TaskDueDateValueObject
    @Published var isDone: Bool
    private(set) var createdAt: Date
    private(set) var updatedAt: Date
    
    // MARK: 初期化init
    init(
        id: UUID = UUID(),
        title: String,
        note: String,
        dueDate: Date? = nil,
        isDone: Bool = false
    ) throws {      
        self.id = id
        self.title = try TaskTitleValueObject(title)
        self.note = TaskNoteValueObject(note)
        
        self.dueDate = try TaskDueDateValueObject(dueDate)
        self.isDone = isDone
        
        let currentTimestamp = Date()
        
        self.createdAt = currentTimestamp
        self.updatedAt = currentTimestamp
    }
    
    (中略)
    
    func setTitleTo(_ title: String) throws {
        self.title = try TaskTitleValueObject(title)
        self.setUpdatedAt()
    }
    
    func setNoteTo(_ note: String) {
        self.note = TaskNoteValueObject(note)
        self.setUpdatedAt()
    }
    
    func setDueDateTo(_ dueDate: Date?) throws {
        self.dueDate = try TaskDueDateValueObject(dueDate)
        self.setUpdatedAt()
    }
    
    func toggleIsDone() {
        self.isDone.toggle()
    }
    
    private func setUpdatedAt() {
        self.updatedAt = Date()
    }
    (中略)
}

先述のドメインモデル図に対応しているので、確認してみてください。

Infrastructure層の実装

続いて、Infrastructure層です。
Infrastructure層ではUseCase層からの要求に対して、永続化データとやり取りをするRepositoryにフォーカスを当てていきます。

インターフェースであるTaskRepositoryProtocolと実装クラスTaskRepositoryを順に載せています。

TaskRepository.swift
protocol TaskRepositoryProtocol {
    
    func create(_ task: TaskEntity) throws
    func fetchById(_ id: UUID) throws -> TaskEntity?
    func fetchAll() throws -> [TaskEntity]
    func update(_ task: TaskEntity) throws
    func delete(_ task: TaskEntity) throws
    
}

実装クラスのTaskRepositoryはCoreDataのリポジトリとして、CoreDataに依存した書き方となっています。
仮にCoreDataではなくHTTP経由でサーバからデータを取得する場合、上記のprotocolを準拠した形で、HTTP通信に依存した実装クラスを別に用意することになります。

TaskRepository.swift
final class TaskRepository: TaskRepositoryProtocol {
    
    private let viewContext: NSManagedObjectContext
    
    init(_ viewContext: NSManagedObjectContext) {
        self.viewContext = viewContext
    }
    
    func create(_ task: TaskEntity) throws {
        let newTask = Task(context: viewContext)
        newTask.id = task.id
        newTask.title = task.title.value
        newTask.note = task.note.value
        newTask.dueDate = task.dueDate.value
        newTask.isDone = task.isDone
        newTask.createdAt = task.createdAt
        newTask.updatedAt = task.updatedAt
        do {
            try viewContext.save()
        } catch {
            throw Error.creationFailed
        }
    }
    
    func fetchById(_ id: UUID) throws -> TaskEntity? {
        let fetchRequest: NSFetchRequest<Task> = Task.fetchRequest()
        fetchRequest.predicate = NSPredicate(
            format: "id == %@", id as NSUUID
        )
        
        do {
            let task = try viewContext.fetch(fetchRequest).first
            guard let task else {
                return nil
            }
            
            return try TaskEntity(
                id: task.id!,
                title: task.title!,
                note: task.note!,
                dueDate: task.dueDate,
                isDone:task.isDone
            )
        } catch {
            throw Error.fetchingFailed
        }
    }
    
    func fetchAll() throws -> [TaskEntity] {
        let fetchRequest: NSFetchRequest<Task> = Task.fetchRequest()
        
        do {
            let tasks = try viewContext.fetch(fetchRequest)
            return try tasks.map { task in
                try TaskEntity(
                    id: task.id!,
                    title: task.title!,
                    note: task.note!,
                    dueDate: task.dueDate,
                    isDone:task.isDone
                )
            }
        } catch {
            throw Error.fetchingFailed
        }
        
    }
    
    func update(_ task: TaskEntity) throws {
        let fetchRequest: NSFetchRequest<Task> = Task.fetchRequest()
        fetchRequest.predicate = NSPredicate(
            format: "id == %@", task.id as NSUUID
        )
        let fetchedTask = try viewContext.fetch(fetchRequest).first
        
        guard let fetchedTask else {
            return
        }
        
        fetchedTask.title = task.title.value
        fetchedTask.note = task.note.value
        fetchedTask.dueDate = task.dueDate.value
        fetchedTask.isDone = task.isDone
        fetchedTask.updatedAt = task.updatedAt
        
        do {
            guard viewContext.hasChanges else { return }
            try viewContext.save()
        } catch {
            throw Error.updatingFailed
        }
    }
    
    func delete(_ task: TaskEntity) throws {
        let fetchRequest: NSFetchRequest<Task> = Task.fetchRequest()
        fetchRequest.predicate = NSPredicate(
            format: "id == %@", task.id as NSUUID
        )
        let fetchedTask = try viewContext.fetch(fetchRequest).first
        guard let fetchedTask else { return }
        
        viewContext.delete(fetchedTask)
    }
    
}

UseCase層の実装

続いて、UseCase層の実装です。
View層からの要求に対して、永続化依頼や永続データのフェッチ要求をInfrastructure層へ伝搬する役割です。

同様に、インターフェースであるTaskUseCaseRepositoryProtocolと実装クラスTaskUseCaseを順に載せています。

TaskUseCaseRepository.swift
protocol TaskUseCaseProtocol {
    
    func saveTask(_ task: TaskEntity) throws
    func updateTask(_ task: TaskEntity) throws
    func fetchAllTasks() throws ->  [TaskEntity]
    func deleteTask(_ task: TaskEntity) throws
    
}
TaskUseCaseRepository.swift
struct TaskUseCase: TaskUseCaseProtocol {
    
    let taskRepository: TaskRepositoryProtocol
    
    func saveTask(_ task: TaskEntity) throws {
        try taskRepository.create(task)
    }
    
    func updateTask(_ task: TaskEntity) throws {
        try taskRepository.update(task)
    }
    
    func fetchAllTasks() throws -> [TaskEntity] {
        return try taskRepository.fetchAll()
    }
    
    func deleteTask(_ task: TaskEntity) throws {
        try taskRepository.delete(task)
    }
    
}
個人的なダメ出し

UseCase層からPresentation層への戻り値の型をドメインオブジェクトにしている。

今回の場合、詰め替えコストが発生しないことと引き換えに、以下のデメリットの影響を受けている。

  • Viewの責務の処理が混入しやすい
  • Domain層の修正の影響がPresentation層に伝わりやすい

実際、エンティティTaskEntityにView層の責務を混入してしまっている🤦‍♂️
↓↓↓

TaskEntity.swift
final class TaskEntity: Identifiable, ObservableObject {
    (中略)
    var formattedDueDate: String? {
        guard let dueDate = dueDate.value else { return nil }
        let dateFormatter = DateFormatter()
        dateFormatter.calendar = Calendar.autoupdatingCurrent
        dateFormatter.dateStyle = .long
        dateFormatter.timeStyle = .none
        dateFormatter.locale = .autoupdatingCurrent
        dateFormatter.timeZone = .autoupdatingCurrent
        
        return dateFormatter.string(from: dueDate)
    }
    
    var dateFormatterForTimestamp: DateFormatter {
        let dateFormatter = DateFormatter()
        dateFormatter.calendar = Calendar.autoupdatingCurrent
        dateFormatter.dateStyle = .short
        dateFormatter.timeStyle = .short
        dateFormatter.locale = .autoupdatingCurrent
        dateFormatter.timeZone = .autoupdatingCurrent
        
        return dateFormatter
    }
}

Presentation層への戻り値クラスを別で用意し詰め替える処理が必要そう。

Presentation層の実装

最後にPresentation層の実装です。
ViewとViewModelをそれぞれ1つずつ紹介してこのセクションを締めたいと思います。

ToDoView.swift
import SwiftUI

struct ToDoView: View {
    
    @EnvironmentObject private var router: Router
    @StateObject private var vm: ToDoViewModel = ToDoViewModel()
    
    var body: some View {
        ScrollView {
            if let errorMessage = vm.errorMessage {
                ContentUnavailableView("Error", systemImage: "exclamationmark.triangle.fill", description: Text(errorMessage))
            } else {
                LazyVStack(spacing: 12) {
                    ForEach(vm.tasks.indices, id: \.self) { index in
                        ToDoRowView(vm.tasks[index]) {
                            vm.toggleIsDone(index)
                        } action: {
                            router.navigate(to: .taskDetail(vm.tasks[index]))
                        }
                    }
                }
                .padding(.horizontal)
            }
            
        }
        .onAppear {
            vm.onAppear()
        }
        .navigationTitle("一覧")
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button {
                    vm.showSheet.toggle()
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
        .sheet(isPresented: $vm.showSheet, onDismiss: vm.onDismiss) {
            ToDoCreateView()
        }
    }
}
ToDoViewModel.swift
import Foundation
import Combine

final class ToDoViewModel: ObservableObject {
    
    @Published var tasks: [TaskEntity] = [] {
        didSet {
            tasks.forEach { task in
                task.objectWillChange
                    .sink { [unowned self] _ in
                        print("Changed")
                        objectWillChange.send()
                    }
                    .store(in: &cancellables)
            }
        }
    }
    @Published var showSheet: Bool = false
    @Published var errorMessage: String?
    var cancellables: Set<AnyCancellable> = []
    
    func onAppear() {
        loadTasks()
    }
    
    func onDismiss() {
        loadTasks()
    }
    
    func toggleIsDone(_ index: Int) {
        tasks[index].toggleIsDone()
        do {
            try AppDependency.shared.taskUseCase.updateTask(tasks[index])
        } catch {
            
        }
    }
    
    private func loadTasks() {
        do {
            tasks = try AppDependency.shared.taskUseCase.fetchAllTasks()
        } catch {
            
        }
    }

}

まとめ

今回の記事では、MVVM+Clean Architectureの構成でToDoアプリを作成しました。
実践を通して、責務の分割を今まで以上に意識づけられたと思います。

特に、筆者自身のコードを見返したときに、「ドメインオブジェクトにViewの処理が混入している」「UseCase層の戻り値クラスにドメインオブジェクトをそのまま利用している」など反省点がありました。

本を読み進めているうちに、ドメイン駆動設計がまだまだ奥深い世界であることを知ったので、今後も引き続き勉強を重ねながら習得していきます。


参考

今回、このテーマに取り組む上で学んだこと・参考にしたものをこちらにリストアップしています。

書籍

アーキテクチャ系

https://booth.pm/ja/items/1835632

https://books.apple.com/jp/book/ドメイン駆動設計入門-ボトムアップでわかる-ドメイン駆動設計の基本/id1500604670

https://peaks.cc/books/iOS_architecture

ライブラリ系

https://www.bigmountainstudio.com/combine

記事

アーキテクチャ系

https://qiita.com/inokinn/items/7c421f3742e7c0380cdc

テスト系

https://zenn.dev/ojun_9/articles/66e785511dff2d

https://zenn.dev/holoholo/articles/f124056e9b76c8#スパイ(test-spy)

https://goyoki.hatenablog.com/entry/20120301/1330608789

ライブラリ系

https://dev.classmethod.jp/articles/swinject-dependency-injection/

https://qiita.com/TokyoYoshida/items/049ed55edf5e624ce0c3

https://github.com/Swinject/Swinject

Discussion