🏗️

サーバーサイドSwiftを使う場合のiOSアーキテクチャ

に公開

目次

  1. はじめに
  2. 従来のアーキテクチャの振り返り
  3. サーバーサイドSwiftを使う場合の前提
  4. 各層の変化
  5. 新しいパッケージ構成
  6. まとめ

1. はじめに

本記事は「サーバーサイドSwiftでiOSアプリ開発をどこまで効率化できるか」シリーズの第5回です。

以前の記事「ViewModelを使わない、SwiftUIらしいiOSアーキテクチャ」では、iOSアプリ単体を前提とした6パッケージ構成のアーキテクチャを提唱しました。iOSアプリを単体で構築する場合は、そのアーキテクチャで問題ありません。

本記事では、サーバーサイドSwiftを使う場合に、そのアーキテクチャがどのように変化するかを解説します。本シリーズで紹介してきたライブラリ群(swift-api-contractswift-statableswift-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マクロにより、valueisLoadingerrorなどのプロパティと、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層の責務に集中できるようになります。


参考リンク

シリーズ記事

関連記事

サンプルリポジトリ

著者

Discussion