サーバーサイドSwiftを使う場合のiOSアーキテクチャ
目次
1. はじめに
本記事は「サーバーサイドSwiftでiOSアプリ開発をどこまで効率化できるか」シリーズの第5回です。
以前の記事「ViewModelを使わない、SwiftUIらしいiOSアーキテクチャ」では、iOSアプリ単体を前提とした6パッケージ構成のアーキテクチャを提唱しました。iOSアプリを単体で構築する場合は、そのアーキテクチャで問題ありません。
本記事では、サーバーサイドSwiftを使う場合に、そのアーキテクチャがどのように変化するかを解説します。本シリーズで紹介してきたライブラリ群(swift-api-contract、swift-statable、swift-api-server)を組み合わせることで、iOS側のアーキテクチャはよりシンプルになります。
2. 従来のアーキテクチャの振り返り
6パッケージ構成
以前の記事で提唱したアーキテクチャは、以下の6パッケージで構成されていました。
各層の責務
| パッケージ | 責務 |
|---|---|
| Domain | ビジネスエンティティ・値オブジェクト(UI非依存) |
| State | @Observable状態管理(ビジネスロジックを持たない) |
| UseCases | Viewから呼び出されるビジネスロジック |
| Repository | API通信・データ永続化 |
| Services | 外部SDK連携(HealthKit, CoreLocation等) |
| Presentation | SwiftUI View・UIコンポーネント |
このアーキテクチャの核心は、Stateがビジネスロジックを持たず、「状態を保持する箱」に徹するという設計思想でした。SPMパッケージ分割により、StateパッケージはDomainのみに依存し、UseCasesには依存できないため、ビジネスロジックの混入を構造的に防いでいました。
3. サーバーサイドSwiftを使う場合の前提
サーバーサイドSwiftを使う場合、以下の前提が変わります。
前提の変化
| 観点 | iOSアプリ単体 | サーバーサイドSwift |
|---|---|---|
| ドメインモデル | iOSアプリ固有 | サーバーとiOSで共有 |
| ビジネスロジック | iOS・サーバー両方に存在 | サーバー側に集約しやすい |
| API定義 | クライアント・サーバーで別々に定義 | swift-api-contractで型を共有 |
シリーズで紹介したライブラリの役割
本シリーズで紹介したライブラリは、それぞれ以下の役割を担います。
- swift-api-contract: API契約をSwiftの型で定義し、サーバーとiOSで共有
- swift-statable: State層の設計思想をマクロで仕組み化
- swift-api-server: サーバー実装の詳細を隠蔽し、ビジネスロジックに集中
これらを組み合わせることで、アーキテクチャ全体が変化します。
4. 各層の変化
4.1 Domain層 → Sharedパッケージへ
Before(iOSアプリ単体)
// iOS/Packages/Domain/User.swift
public struct User: Sendable, Identifiable {
public let id: String
public let name: String
public let email: String
}
ドメインモデルはiOSアプリ固有のパッケージとして定義していました。
After(サーバーサイドSwift)
// Shared/Sources/Shared/Entities/User.swift
public struct User: Codable, Sendable, Identifiable, Hashable {
public let id: String
public let name: String
public let email: String
}
ドメインモデルはSharedパッケージに移動し、サーバーとiOSの両方から参照します。API契約で使用するDTOも同じパッケージに定義します。
// Shared/Sources/Shared/Entities/CreateTodoInput.swift
public struct CreateTodoInput: Codable, Sendable {
public let title: String
public let description: String?
public let categoryId: String?
public let dueDate: Date?
}
メリット:
- 型の二重定義が不要
- サーバーとiOSで同じ型を使うため、デコードエラーが原理的に発生しない
4.2 State層 → Presentationパッケージへ統合
Before(iOSアプリ単体)
State層を独立したパッケージとして分離し、手動で状態管理のコードを書いていました。
// iOS/Packages/State/TodoState.swift
@MainActor
@Observable
public final class TodoState {
public private(set) var todos: [Todo] = []
public private(set) var isLoading = false
public private(set) var error: Error?
public var incompleteTodos: [Todo] {
todos.filter { !$0.isCompleted }
}
public func setTodos(_ todos: [Todo]) {
self.todos = todos
self.error = nil
}
public func setLoading(_ isLoading: Bool) {
self.isLoading = isLoading
}
public func setError(_ error: Error?) {
self.error = error
self.isLoading = false
}
}
After(サーバーサイドSwift)
swift-statableの@Statableマクロを使い、Presentationパッケージ内にStoreを定義します。
// iOS/Packages/Presentation/Stores/TodoStore.swift
@Statable([Todo].self)
@MainActor @Observable
public final class TodoStore {
nonisolated public init() {}
public var incompleteTodos: [Todo] {
(value ?? []).filter { !$0.isCompleted }
}
}
@Statableマクロにより、value、isLoading、errorなどのプロパティと、set()、load()、reset()などのメソッドが自動生成されます。
なぜPresentationに統合するのか:
-
@Statableマクロで設計思想(ビジネスロジックを持たない)は維持される -
@EntryマクロでSwiftUI Environmentに自然に統合できる - UI上の状態管理という意味で、Presentationに含めるのが自然
// iOS/Packages/Presentation/Environment/Stores.swift
extension EnvironmentValues {
@Entry public var todoStore: TodoStore = .init()
@Entry public var categoryStore: CategoryStore = .init()
}
4.3 Repository層 → パッケージとして分離しない
Before(iOSアプリ単体)
// iOS/Packages/Repository/UserRepository.swift
public protocol UserRepository: Sendable {
func getUser(id: String) async throws -> User
func updateUser(_ user: User) async throws -> User
}
// iOS/Packages/Repository/UserRepositoryImpl.swift
public struct UserRepositoryImpl: UserRepository {
private let apiClient: APIClient
private let localStorage: LocalStorage
public func getUser(id: String) async throws -> User {
...
}
}
iOSアプリ単体では、Repository層がAPIアクセスやローカルDBへの永続化を抽象化していました。
After(サーバーサイドSwift)
iOS側でRepository層をパッケージとして分離する必要がなくなります。
理由:
swift-api-contractとAPIクライアントライブラリにより、Repository層が担っていたAPIの呼び出し部分がマクロで自動生成・隠蔽されます。わざわざ別レイヤーとしてボイラープレートを書く必要がありません。
let todos = try await TodosAPI.List(categoryId: nil, limit: 20).execute(using: apiClient)
ローカルキャッシュなど、レイヤーとして必要になる場合はUseCases内に実装を追加することもできます。
4.4 UseCases層 → API契約の呼び出しを含む
UseCases層の役割は基本的に変わりません。ただし、Repository層をパッケージとして分離しなくなったため、API契約をAPIクライアントで呼び出す責務もUseCases内に含まれるようになります。
// iOS/Packages/UseCases/TodoUseCaseImpl.swift
public struct TodoUseCaseImpl<Executor: APIExecutable>: TodoUseCase, Sendable {
private let executor: Executor
public init(executor: Executor) {
self.executor = executor
}
public func getTodos(
categoryId: String?,
isCompleted: Bool?,
limit: Int,
offset: Int
) async throws -> [Todo] {
try await TodosAPI.List(
categoryId: categoryId,
isCompleted: isCompleted,
limit: limit,
offset: offset
).execute(using: executor)
}
public func createTodo(input: CreateTodoInput) async throws -> Todo {
try await TodosAPI.Create(input: input).execute(using: executor)
}
}
API契約の定義から呼び出しコードが自動生成されるため、従来Repository層で書いていたボイラープレートは不要になります。
5. 新しいパッケージ構成
iOS側のパッケージ構成は以下のようになります。
変化の比較
| 層 | iOSアプリ単体 | サーバーサイドSwift |
|---|---|---|
| Domain | iOSパッケージ | Sharedへ統合 |
| State | 独立パッケージ | Presentationへ統合 |
| Repository | iOS側に存在 | パッケージとして分離しない |
| Presentation | View | View + Store |
6. まとめ
サーバーサイドSwiftを使う場合、iOS側のパッケージ構成はシンプルになります。
| 層 | 変化 |
|---|---|
| Domain | Sharedパッケージへ統合 |
| State | Presentationパッケージへ統合(@Statableで自動生成) |
| Repository | パッケージとして分離しない(マクロで隠蔽) |
「Stateはビジネスロジックを持たない」「Viewは状態とUseCaseを組み合わせる」という設計思想は維持されています。サーバーサイドSwiftを使うことで、型を共有しながらビジネスロジックをサーバー側に効率的に集約できるようになります。これにより、iOS側のアーキテクチャはよりシンプルになり、UI層の責務に集中できるようになります。
参考リンク
シリーズ記事
- 第1回: Apple公式Swift Configurationを試してみた
- 第2回: SwiftUIの状態管理をマクロで仕組み化する
- 第3回: サーバーとクライアントでAPIの型を共有する方法
- 第4回: サーバーサイドSwiftをより使いやすく
- 第5回: 本記事
関連記事
サンプルリポジトリ
- swift-app-template - 本記事のアーキテクチャを実装したテンプレート
Discussion