【SwiftUI】MVVM + Clean ArchitectureでToDoアプリ作ってみた
はじめに
今回はSwiftUIとClean Architectureに焦点を当て、アーキテクチャについて学んだことを記事にまとめています。
インプットである書籍や記事をベースに、Domain層・Infrastructure層・UseCase層・Presentation層の順にボトムアップで実装する過程を言語化しております。
今回のプロジェクトのテストコードもユニットテストレベルですが、限られた時間で書いてみました。
記事の構成
- 【設計】ToDoアプリの構成とドメインモデルの設計
- 全体の構成
- モデルの設計
- 【実装】Clean Architectureをベースにボトムアップで実装
- Domain層の実装
- Infrastructure層の実装
- UseCase層の実装
- Presentation層の実装
- まとめ
- 参考
コードについて
コードをGitHubに公開しています。
中身を見たい方は以下のリンクからチェックしてみてください。
また、コードに関するフィードバックも大歓迎です。
GitHubリポジトリへのリンク
環境
- Xcode 16.0
- Swift 6.0
外部ライブラリ
Dependency Injection用にSwinjectを採用してます。
今回のテーマ: ToDoアプリ
ToDoアプリをテーマとしました。
今回取り挙げるテーマから以下を学習していきます。
- CRUD処理を一通りUseCase層で実装し、Presentation層とUseCase層の処理の流れを理解する
- シンプルな要件でドメイン知識をDomain層に表現する
- 要件を満たすコードをユニットテストを書きながら組み立てる
本記事では、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
を例に挙げて、ドメイン知識を値オブジェクトに落とし込んでいます。
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します。
[補足] エラーの定義
extension TaskTitleValueObject {
enum Error: LocalizedError {
case titleTooLong
case titleTooShort
var errorDescription: String? {
switch self {
case .titleTooLong: "タイトルが長すぎます。"
case .titleTooShort: "タイトルが短すぎます。"
}
}
}
}
エンティティTaskEntity
も一部載せておきます。
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
を順に載せています。
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通信に依存した実装クラスを別に用意することになります。
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
を順に載せています。
protocol TaskUseCaseProtocol {
func saveTask(_ task: TaskEntity) throws
func updateTask(_ task: TaskEntity) throws
func fetchAllTasks() throws -> [TaskEntity]
func deleteTask(_ task: TaskEntity) throws
}
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層の責務を混入してしまっている🤦♂️
↓↓↓
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つずつ紹介してこのセクションを締めたいと思います。
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()
}
}
}
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層の戻り値クラスにドメインオブジェクトをそのまま利用している」など反省点がありました。
本を読み進めているうちに、ドメイン駆動設計がまだまだ奥深い世界であることを知ったので、今後も引き続き勉強を重ねながら習得していきます。
参考
今回、このテーマに取り組む上で学んだこと・参考にしたものをこちらにリストアップしています。
書籍
アーキテクチャ系
ライブラリ系
記事
アーキテクチャ系
テスト系
ライブラリ系
Discussion