はじめに
こんにちは、ゆきおです。
今日はフロントエンド開発において使えそうな、アーキテクチャを考案しましたので記事にしていこうと思います。
最近React/TSのプロジェクトに参画し、その経験を元にGeminiとあーでもないこーでもないとやり取りし、「直感的でわかりやすい」アーキテクチャを目指してひとまず第一歩を踏み出したところです。
まずはモバイルのプロジェクトを想定し、MVVMを元に考えました。
名前を「FOCAL」と名付けました(Geminiが
Frontend(もしくはFeatured) Oriented Clean Architecture Layerという少々無理矢理な名前ですが良いネーミングですね。
Focal = 焦点というダブルミーニングでもあり、機能ごとに焦点を当てたディレクトリ構成を意識しています。
まだまだ粗や足りないところは多いので、どんどんアップデートしていこうと思います。
サンプル
ディレクトリ構成
とりあえずiOSで簡単にログインやログアウトの実装をする想定でディレクトリ構成を考えてみました。
/YourApp
│
├── Core/ # 複数の機能で共有される基盤
│ │
│ ├── Data/ # APIなど外部データソースとの「契約」
│ │ ├── Requests/
│ │ └── Responses/
│ │
│ ├── Domain/ # アプリケーションの「ビジネスルール」の中心
│ │ └── Models/
│ │ └── User.swift
│ │
│ ├── Network/ # ネットワーク通信クライアント
│ │ ├── APIClient.swift
│ │ ├── FakeAPIClient.swift(仮API)
│ │ └── APIError.swift
│ │
│ ├── Presentation/ # 共有されるUI関連
│ │ ├── Components/
│ │ └── Pages/
│ │ ├── ContentView.swift
│ │ └── Home/
│ │ └── HomeView.swift
│ └── Utils/
│
└── Features/ # 自己完結した機能モジュール
│
├── Authentication/
│ ├── Domain/
│ │ ├── Errors/
│ │ │ └── AuthError.swift
│ │ ├── Repositories/
│ │ │ └── AuthRepository.swift
│ │ └── UseCases/
│ │ └── AuthUseCase.swift
│ └── Presentation/
│ ├── Models/
│ │ └── AuthenticatedUser.swift
│ ├── ViewModel/
│ │ ├── AuthState.swift
│ │ ├── AuthActions.swift
│ │ └── AuthStore.swift
│ └── Views/
│ └── Pages/
│ └── LoginView.swift
│
├── Feed/
│ ├── Domain/
│ │ └── ...
│ └── Presentation/
│ ├── ViewModel/
│ │ ├── FeedState.swift
│ │ ├── FeedActions.swift
│ │ └── FeedStore.swift
│ └── Views/
│ └── ...
│
└── MyPage/
├── Domain/
│ └── ...
└── Presentation/
├── ViewModel/
│ ├── MyPageState.swift
│ ├── MyPageActions.swift
│ └── MyPageStore.swift
└── Views/
└── ...
まず、機能ごとにディレクトリを作成してそこにUIもロジックも入れる形にしました。
UI、Usecase、Repositoryなど分かれていると行ったり来たりが大変だなあといつも思っていたので、こうしてみたらどうだろうとずっと思っていたので採用しました。
共通で使うものはCoreディレクトリに入っています。
機能ごとに焦点を当てているので、Feature Orientedなわけですね。
そしてViewModelの部分を細かく分割してみました。
Laravel開発でよく使われるADRパターンのような発想ですね。
こうして分割することでちょっと細かいですが、どこに何が書いてあるかがより明確になり、機能が違っても内容がテンプレ化して見通しの良さや実装のしやすさが向上するのでは?というのが狙いです。
モデル
モデルの定義は、まずCore側で定義します
// Core/Domain/Models/User.swift
import Foundation
/// アプリケーションのビジネスロジックの中心となる、完全なユーザーモデル
struct User: Equatable {
let id: String
let name: String
let email: String
let lastLoginDate: Date
}
また、APIのリクエストやレスポンスのモデルに関しては共通ディレクトリの中のDataディレクトリで定義します。
ResponseやRequestディレクトリに定義します
// Core/Data/Request/LoginRequest.swift
import Foundation
/// ログインAPIのPOSTリクエストボディ
struct LoginRequest: Encodable {
let email: String
let pass: String
}
// Core/Data/Response/UserResponce.swift
import Foundation
/// ユーザー情報を取得するAPIのレスポンスを表現する
struct UserResponse: Decodable {
let userId: String
let userName: String
let userEmail: String
let lastLogin: Date
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case userName = "user_name"
case userEmail = "email"
case lastLogin = "last_login_date"
}
}
各ビューで表示に使う部分を各機能のディレクトリ内で定義します
struct AuthenticatedUser: Equatable {
let name: String
}
Repository,Usecase
次にRepository層です。ここはあくまでAPIとの通信のみに役割を絞ります。
インターフェース(protocol)を実装し、その詳細をImplで実装します。
とりあえずAlamofireなどライブラリを使う想定ではいますがサンプルとして何にもない状態です。
// Features/Authentication/Domain/Repositories/AuthRepository.swift
import Foundation
protocol AuthRepository {
func login(email: String, pass: String) async throws -> UserResponse
func logout() async throws
}
class AuthRepositoryImpl: AuthRepository {
// ...
func login(email: String, pass: String) async throws -> UserResponse {
do {
let requestBody = LoginRequest(email: email, pass: pass)
let response: UserResponse = try await apiClient.post(path: "/v1/login", body: requestBody)
return response
} catch let error as APIError {
switch error {
case .invalidResponse:
throw AuthError.invalidCredentials
case .requestFailed:
throw AuthError.noNetwork
default:
throw AuthError.serverError(reason: "An unexpected error occurred.")
}
}
}
func logout() async throws {
try await apiClient.post(path: "/v1/logout", body: EmptyRequestBody())
}
}
Usecaseでは、APIから取得したデータの変換などを行います。(もう少し具体的な例を作りたいですね。。。
// Features/Authentication/Domain/UseCases/AuthUseCase.swift
import Foundation
/// 認証関連のビジネスロジックをまとめたUseCase
class AuthUseCase {
private let repository: AuthRepository
init(repository: AuthRepository) {
self.repository = repository
}
/// ログイン処理
func login(email: String, pass: String) async throws -> AuthenticatedUser {
do {
let response = try await repository.login(email: email, pass: pass)
let User = User(
id: response.userId,
name: response.userName,
email: response.userEmail,
lastLoginDate: response.lastLogin
)
// CoreモデルからUI表示用のモデルに変換して返す
return AuthenticatedUser(name: User.name)
} catch let error as AuthError {
// ドメインエラーを解釈し、必要に応じてログ送信などの処理を行う
// Logger.shared.log(error)
throw error // UI層にエラーを伝えるために再スロー
}
}
/// ログアウト処理
func logout() async throws {
try await repository.logout()
}
}
ViewModel
データの取得、変換が終わったので次はViewModelに入ります
ViewModelでは「State」「Store」「Action」の3つに分かれています
State - 状態の定義
責務: この機能のUIが取りうる全ての状態をenumで定義します。これにより、予期せぬ状態が存在しなくなり、UIの状態管理が安全かつ明確になります。
// Features/Authentication/Presentation/ViewModel/AuthState.swift
/// 認証画面のUI状態を表現するenum
enum AuthState: Equatable {
case loading // ローディング中
case signedIn(user: AuthenticatedUser) // ログイン済み
case signedOut // ログアウト状態
case error(String) // エラーが発生した状態
}
Store - 状態の保管と通知
責務: AuthStateの値を実際に保管し、@Publishedを通じてUIに変更を通知するハブ(司令塔)です。また、ビジネスロジック(UseCase)への依存性を保持する役割も担います。
Actionsプロトコルは、Viewに対して「何ができるか」という操作のAPIを公開します。Storeのextensionは、その操作が具体的に何を行うか(UseCaseを呼び出し、Stateを更新する)を実装します。
// Features/Authentication/Presentation/ViewModel/AuthStore.swift
import SwiftUI
@MainActor
class AuthStore: ObservableObject {
@Published private(set) var state: AuthState
let authUseCase: AuthUseCase
init(authUseCase: AuthUseCase) {
self.state = .signedOut
self.authUseCase = authUseCase
}
}
// AuthStoreの操作(Action)を定義する
extension AuthStore: AuthActions {
/// ログインアクション
func login(email: String, pass: String) async {
self.state = .loading
do {
let user = try await authUseCase.login(email: email, pass: pass)
self.state = .signedIn(user: user)
} catch let error as AuthError {
switch error {
case .invalidCredentials:
self.state = .error("メールアドレスまたはパスワードが間違っています。")
case .noNetwork:
self.state = .error("ネットワーク接続を確認してください。")
case .serverError:
self.state = .error("サーバーで問題が発生しました。時間をおいて再試行してください。")
}
} catch {
self.state = .error("不明なエラーが発生しました。")
}
}
/// ログアウトアクション
func logout() async {
do {
try await authUseCase.logout()
} catch {
print("Logout failed: \(error.localizedDescription)")
}
self.state = .signedOut
}
}
Actions - 状態の操作
責務: UIからのイベントをトリガーに、状態を操作するための**「契約(protocol)」を定義します
// Features/Authentication/Presentation/ViewModel/AuthActions.swift
// 認証機能に関する操作(Action)の契約を定義するプロトコル
protocol AuthActions {
func login(email: String, pass: String) async
func logout() async
}
こんな感じでViewModelを3つに分解して実装するようにしてみました。
それぞれに何を書けば良いかを明確にし、可読性やメンテナンス性を保つ狙いです。
View
最後に、Viewでどう呼び出すかのイメージです。
今後これとは別に、何かわかりやすいサンプルアプリを作ろうと思います。
ホーム画面とログイン画面の出し分けをする画面
// Core/Presentation/Pages/ContentView.swift
import SwiftUI
struct ContentView: View {
// 環境オブジェクトとしてAuthStoreを購読
@EnvironmentObject var authStore: AuthStore
var body: some View {
// AuthStoreの状態に応じて表示するViewを切り替える
switch authStore.state {
case .signedIn:
HomeView()
case .signedOut, .loading, .error:
LoginView()
}
}
}
ログイン画面
// Features/Authentication/Presentation/Views/Pages/LoginView.swift
import SwiftUI
struct LoginView: View {
@EnvironmentObject var store: AuthStore
// フォームの入力値を管理
@State private var email = ""
@State private var password = ""
var body: some View {
VStack(spacing: 16) {
Text("FOCAL Architecture Demo")
.font(.largeTitle)
// エラーメッセージの表示
if case .error(let message) = store.state {
Text(message)
.foregroundColor(.red)
.multilineTextAlignment(.center)
}
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.autocapitalization(.none)
.padding()
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray, lineWidth: 1))
SecureField("Password", text: $password)
.textContentType(.password)
.padding()
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray, lineWidth: 1))
// ローディング状態に応じてインジケーターを表示
if store.state == .loading {
ProgressView()
.padding(.vertical, 8)
} else {
// LoginButtonコンポーネントを想定
Button(action: performLogin) {
Text("ログイン")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
}
}
.padding()
}
// MARK: - Methods
private func performLogin() {
Task {
await store.login(email: email, pass: password)
}
}
}
ホーム画面
// Core/Presentation/Pages/HomeView.swift
import SwiftUI
struct HomeView: View {
@EnvironmentObject var store: AuthStore
var body: some View {
VStack(spacing: 20) {
// ログイン済みの状態からユーザー情報を取得して表示
if case .signedIn(let user) = store.state {
Text("ようこそ、\(user.name)さん")
.font(.title)
}
Button(action: performLogout) {
Text("ログアウト")
.foregroundColor(.red)
}
}
}
// MARK: - Methods
private func performLogout() {
Task {
await store.logout()
}
}
}
とまあこんな感じです。
おわり
ひとまず思いついた限りをGeminiと相談して設計してみました。
実際に作ってみると何かしらエラーが出るかもしれんので、今後はサンプルアプリを作ってみてブラッシュアップしていこうと思います。