記事修正
PresenterがUseCaseから直接呼ばれていたので、OutPut経由で通知されるように依存関係を修正しました。(2022/12/22)
投稿の経緯
私が参加しているコミュニティにてCleanArchitectureの輪読会をしました。ただ読むだけでは学びにならないので、サンプルアプリを元にアウトプットしていきます。
参加しているコミュニティはこちら👇
CleanArchitectureについて
CleanArchitectureを要約するとドメイン駆動開発(DDD)やユースケース駆動開発(UCDD)を意識して、ビジネスロジックをUIやFrameworkから引き離し、それぞれの層毎に役割と責任を分離したしたArchitectureです。
CleanArchitectureはSOLID原則を突き詰めたArchitectureだと思っています。
SOLID原則とは
- SRP:単一責任の原則
- OCP:オープン・クローズドの原則
- LSP:リスコフの置換原則
- ISP:インターフェイス分離の原則
- DIP:依存関係逆転の原則
これらの原則の頭文字をとってSOLID原則と呼ばれています。
CleanArchitectureのメリット
- UIやフレームワークに依存しない
- ビジネスロジックが明確になる
- 各レイヤーが抽象にしか依存いないので役割分担して開発できる
- テストの導入が容易になる
CleanArchitectureのデメリット
- コード量が多い
- ファイルが多い
- プロトタイプや小規模開発には向かない
- 導入難易度が高い
各レイヤの役割について
Presentation layer
UIの表示やユーザーからのイベントを処理します。ビジネスロジックは処理しない。
View
画面の表示とユーザーからのアクションをControllerに通知します。
ViewModel
Presenterから受けとったデータやステータスによってViewの表示を切り替えます。
Controller
Viewからイベントを受け取ってイベントに応じたUseCaseを実行します。また、UseCaseで使うModel型に値を変換する役割も担当します。
Presenter
UseCaseから受け取ったでデータをViewModelへ渡します。Viewがどうなっているかは知りません。
Domain layer
アプリ固有のビジネスロジックはこのレイヤーが担当します。
Model
Domain層で扱うModel。Viewから受け取ったデータや、Data層のEntityをPresentation層で使うときに必要なModel。
UseCase
ビジネスロジックを処理する。UIやフレームワークなどの外部の変更の影響を受けることはない。
Translator
UseCaseで取得したEntityをPresentation layerで使用するModelへ変換する役割。Viewで使用するために最適化したModelを作成する。
Repository
データの保存や取得に必要なDataStoreへデータ処理のリクエストをおこなう。Domain層として書いていますが、実際はDomain層とData層のパイプ役です。(アダプタ的な役割)
Data layer
データベースやAPI通信などデータに関するのロジックはこのレイヤが担当します。
DataStore
実際のデータの保存や取得する処理を記述する。FirebaseやAPIへのリクエストを投げたりする。
Entity
DataStoreで扱うことができるデータの静的なモデル。Entity自身を直接操作することはなく、valueobjectとして使う。EntityはPresentation層では使用されない。
サンプルアプリ
上記で説明した役割とこちらの図を意識してサンプルプロジェクトを開発しました。全ての機能を紹介していると大変なのでユーザー登録機能のみ紹介します。
コードはGitHubにPushしています。気になる方はご覧ください。
https://github.com/ken-sasaki2/CleanArchitectureTraining
実装
Data layer
ユーザー情報の登録先はFirebaseのFirestoreにしています。
import Firebase
import FirebaseAuth
final class UserRequestToFirestore {
private let reference = Firestore.firestore().collection("users")
private var uid: String {
Auth.auth().currentUser?.uid ?? ""
}
func saveUser(inputEntity: UserAddInputEntity) async throws -> Void {
return try await withCheckedThrowingContinuation { continuation in
reference.document(uid).collection("user").addDocument(data: [
"uid" : uid,
"name" : inputEntity.name,
"gender" : inputEntity.gender,
"createdAt" : inputEntity.createdAt
]) { error in
if let error = error {
print("Error writing document:", error)
continuation.resume(throwing: error)
} else {
print("Success write document.")
continuation.resume(returning: ())
}
}
}
}
}
Firestoreと通信をおこなうclassです。withCheckedThrowingContinuation
を使ってasync/awaitで非同期処理を書いています。引数で受け取ったEntity
を使ってユーザーデータを登録します。
import Foundation
struct UserAddInputEntity {
var name: String
var gender: Int
var createdAt: TimeInterval
init(inputData: UserAddInputData) {
self.name = inputData.name
self.gender = inputData.gender
self.createdAt = inputData.createdAt
}
}
先述したようにEntityは静的なデータです。直接操作することはありません。
import Foundation
protocol UserDataStoreInterface {
func saveUser(inputData: UserAddInputData) async throws -> Void
}
final class UserDataStore: UserDataStoreInterface {
private let userRequest = UserRequestToFirestore()
func saveUser(inputData: UserAddInputData) async throws -> Void {
do {
let inputEntity = UserAddInputEntity(inputData: inputData)
try await userRequest.saveUser(inputEntity: inputEntity)
return
} catch {
throw error
}
}
}
このclassはUserRequestToFirestore
の処理を呼んで結果を受け取ります。Domain層から渡ってきたModelをEntityに変換しています。
Data層のコードは以上です。
Domain layer
import Foundation
protocol UserRepositoryInterface {
func saveUser(inputData: UserAddInputData) async throws -> Void
}
final class UserRepository: UserRepositoryInterface {
private let userDataStore: UserDataStoreInterface
init(userDataStore: UserDataStoreInterface) {
self.userDataStore = userDataStore
}
func saveUser(inputData: UserAddInputData) async throws -> Void {
do {
try await userDataStore.saveUser(inputData: inputData)
return
} catch {
throw error
}
}
}
RepositoryはDomain層とData層を繋ぐパイプ役のような役割です。
import Foundation
struct UserAddInputData {
var name: String
var gender: Int
var createdAt: TimeInterval
init(name: String, gender: Int, createdAt: TimeInterval) {
self.name = name
self.gender = gender
self.createdAt = createdAt
}
}
ユーザーの入力情報を格納するModelです。
import Foundation
protocol UserAddUseCaseInput {
func saveUser(inputData: UserAddInputData) async throws -> Void
}
protocol UserAddUseCaseOutput {
func invalidUserName()
func invalidGender()
func successSaveUser()
func failSaveUser()
}
final class UserAddUseCase: UserAddUseCaseInput {
private let userRepository: UserRepositoryInterface
private let output: UserAddUseCaseOutput
init(userRepository: UserRepositoryInterface, output: UserAddUseCaseOutput) {
self.userRepository = userRepository
self.output = output
}
func saveUser(inputData: UserAddInputData) async throws -> Void {
do {
let isValidUserName = isValidUserName(name: inputData.name)
let isValidGender = isValidGender(gender: inputData.gender)
if !isValidUserName {
output.invalidUserName()
return
}
if !isValidGender {
output.invalidGender()
return
}
try await userRepository.saveUser(inputData: inputData)
userRepository.setIsUserDataSaved(isSaved: true)
output.successSaveUser()
} catch {
output.failSaveUser()
}
}
func isValidUserName(name: String) -> Bool {
let isMin = name.count >= 2
let isMax = name.count <= 10
if isMin && isMax {
return true
} else {
return false
}
}
func isValidGender(gender: Int) -> Bool {
switch gender {
case 1:
return true
case 2:
return true
case 3:
return true
default:
return false
}
}
}
アプリ固有のビジネスロジックを管理します。isValidUserName
、isValidGender
がユーザーの入力情報をバリデートしています。その他の役割としてはPresentation層のControllerからユーザーの入力情報を受け取り保存処理を実行し、結果をPresentation層のPresenterへ通知します。
Domain層のコードは以上です。
Presentation layer
import Foundation
final class UserProfileController {
private let userAddUseCase: UserAddUseCaseInterface
init(userAddUseCase: UserAddUseCaseInterface) {
self.userAddUseCase = userAddUseCase
}
func createUser(uid: String, name: String, gender: Int) {
let inputData = UserAddInputData(
name: name,
gender: gender,
createdAt: Date().timeIntervalSince1970
)
Task {
try await userAddUseCase.saveUser(inputData: inputData)
}
}
}
Controllerの役割はユーザーの入力情報をModelに変換し、UseCaseへ通知することです。Viewでおこなわれたユーザーの操作は全てControllerへ通知され処理が始まります。
import Foundation
final class UserAddPresenter: UserAddUseCaseOutput {
var userProfileVM: UserProfileViewModel
init(userProfileVM: UserProfileViewModel) {
self.userProfileVM = userProfileVM
}
func invalidUserName() {
userProfileVM.isShowUserNameAlert = true
}
func invalidGender() {
userProfileVM.isShowGenderAlert = true
}
func successSaveUser() {
userProfileVM.isShowSuccessSaveUserAlert = true
}
func failSaveUser() {
userProfileVM.isShowFailSaveUserAlert = true
}
}
UseCaseから通知されたユーザー情報の登録結果でViewModelを操作してViewの見た目を変化させます。先述した通りPresenterはViewがどうなっているかは知りません。
import Foundation
final class UserProfileViewModel: ObservableObject {
@Published var isShowUserNameAlert = false
@Published var isShowGenderAlert = false
@Published var isShowSuccessSaveUserAlert = false
@Published var isShowFailSaveUserAlert = false
}
Viewに表示するデータやステータスを持っており、Presenterから受けとったデータによってViewの表示を操作します。
import SwiftUI
struct UserProfileView: View {
@ObservedObject var userProfileVM: UserProfileViewModel
@State private var name = ""
@State private var genderSelection = 0
let userProfileController: UserProfileController
private let genders = ["未選択", "男", "女", "選ばない"]
var body: some View {
GeometryReader { geometry in
VStack {
Spacer()
Text("プロフィール登録")
.font(.system(size: 28, weight: .semibold, design: .default))
VStack(spacing: 0) {
Text("必須")
.foregroundColor(.gray)
.font(.system(size: 12, weight: .regular, design: .default))
.frame(width: geometry.size.width / 1.3, alignment: .leading)
TextField("名前", text: $name)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.vertical, 5)
.padding(.horizontal, 35)
.keyboardType(.default)
}
.padding(.top, 20)
VStack(spacing: 0) {
Text("必須")
.foregroundColor(.gray)
.font(.system(size: 12, weight: .regular, design: .default))
.frame(width: geometry.size.width / 1.3, alignment: .leading)
Picker(selection: $genderSelection, label: Text("性別")) {
ForEach(0..<genders.count) { index in
Text(genders[index])
}
}
.pickerStyle(SegmentedPickerStyle())
.frame(width: geometry.size.width / 1.2)
}
.padding(.top, 15)
.padding(.bottom, 30)
RegistrationButtonView {
createUser(name: name, gender: genderSelection)
}
.alert("登録失敗", isPresented: $userProfileVM.isShowUserNameAlert) {
Button("OK") {
name = ""
}
} message: {
Text("2文字以上10文字以下で登録してください")
}
.alert("登録失敗", isPresented: $userProfileVM.isShowGenderAlert) {
Button("OK") {}
} message: {
Text("性別を選択してください")
}
.alert("登録失敗", isPresented: $userProfileVM.isShowFailSaveUserAlert) {
Button("OK") {}
} message: {
Text("登録に失敗しました。通信状態が良好な環境で再度お試しください。")
}
.alert("登録完了", isPresented: $userProfileVM.isShowSuccessSaveUserAlert) {
Button("OK") {}
} message: {
Text("プロフィールを登録しました")
}
Spacer()
}
}
}
}
extension UserProfileView {
private func createUser(name: String, gender: Int) {
userProfileController.createUser(name: name, gender: gender)
}
}
struct UserProfileView_Previews: PreviewProvider {
static var previews: some View {
UserProfileBuilder.shared.build()
}
}
Viewの役割はとてもシンプルで、UIの描画です。
Presentation層のコードは以上です。
依存性の注入
import Foundation
final class UserProfileBuilder {
static let shared = UserProfileBuilder()
private init() {}
func build() -> UserProfileView {
let viewModel = UserProfileViewModel()
let view = UserProfileView(
userProfileVM: viewModel,
userProfileController: UserProfileController(
userAddUseCase: UserAddUseCase(
userRepository: UserRepository(
userDataStore: UserDataStore(),
output: UserAddPresenter(
userProfileVM: viewModel
)
)
)
)
return view
}
}
各classはInterface(抽象)に依存しており、外部から注入してあげる必要があるのでDI(Dependency Injection)します。
DIに関してはこちらの記事を参考にしてください。
テスト
import XCTest
class UserAddUseCaseTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testIsValidUserName() throws {
let dataStore = UserDataStore()
let repository = UserRepository(userDataStore: dataStore)
let viewModel = UserProfileViewModel()
let output = UserAddPresenter(userProfileVM: viewModel)
let useCase = UserAddUseCase(userRepository: repository, output: output)
XCTContext.runActivity(named: "nameが2文字以上10文字以下の場合") { _ in
let name = "xx"
let result = useCase.isValidUserName(name: name)
XCTAssert(result == true)
}
XCTContext.runActivity(named:"nameが2文字未満の場合") { _ in
let name = "x"
let result = useCase.isValidUserName(name: name)
XCTAssert(result == false)
}
XCTContext.runActivity(named:"nameが10文字超過の場合") { _ in
let name = "xxxxxxxxxxx"
let result = useCase.isValidUserName(name: name)
XCTAssert(result == false)
}
}
func testIsValidGender() throws {
let dataStore = UserDataStore()
let repository = UserRepository(userDataStore: dataStore)
let viewModel = UserProfileViewModel()
let output = UserAddPresenter(userProfileVM: viewModel)
let useCase = UserAddUseCase(userRepository: repository, output: output)
XCTContext.runActivity(named: "genderが0の場合") { _ in
let result = useCase.isValidGender(gender: 0)
XCTAssert(result == false)
}
XCTContext.runActivity(named: "genderが1の場合") { _ in
let result = useCase.isValidGender(gender: 1)
XCTAssert(result == true)
}
XCTContext.runActivity(named: "genderが2の場合") { _ in
let result = useCase.isValidGender(gender: 2)
XCTAssert(result == true)
}
XCTContext.runActivity(named: "genderが3の場合") { _ in
let result = useCase.isValidGender(gender: 3)
XCTAssert(result == true)
}
}
}
CleanArchitectureで開発したのでもちろんテストも書いています。(量が多くなるのでほんの一部だけ紹介)
レイヤーや各classの依存関係が疎結合なことからテスト導入難易度も簡易でアーキテクチャの恩恵を受けと思います。
参考にした情報
おわりに
今回はCleanArchitectureに関して記事を書きました。
ファイルの多さや、コード量の多さなど初めは苦戦しましたが、慣れてくるにつれ、テストの容易性やビジネスロジックが明確になる点など、CleanArchitectureの恩恵を受けることもでき、いい学びになりました。
ただ各レイヤーの扱いや依存関係の認識がずれている箇所もあるかもしれないので、ご教示いただけると幸いです。
ご覧いただきありがとうございました!この記事が誰かの役に立てば幸いです。
お知らせ
現在副業でiOSアプリ開発を募集しています。
ご依頼はTwitter DMでお待ちしております。