10年分の技術的負債を解消。ニフティ温泉アプリを最新アーキテクチャで作り直した話
この記事は、ニフティグループ Advent Calendar 2025 25日目の記事です。
こんにちは。モバイルアプリの開発をしているt-toshasegawです。
ニフティライフスタイルでは今年、2015年のリリース以来初となるiOS/Android「ニフティ温泉」アプリの大規模リニューアルを実施しました。
今回のリニューアルでは、温浴施設の特徴が一目で分かる「地図検索」や、「電子チケット」「アプリ限定クーポン」への対応など、より便利に温泉を楽しんでいただけるアップデートを行っています。 詳細はぜひこちらの温泉ニュースをご覧ください。
https://onsen.nifty.com/onsen-matome/250910192320/
さて、このアプリですが、実は2015年のリリース以降、大きな機能追加や技術的なアップデートがほぼ行われていませんでした。そのため、開発環境は現代のベストプラクティスから大きく乖離し、「技術的な負債」が重くのしかかっていました。
今回は、この大規模リニューアルで、iOS版ニフティ温泉の開発環境がどのようにモダンな技術基盤へと生まれ変わったのかをご紹介します。
目次
リニューアル前の開発環境
リニューアル前のiOS版ニフティ温泉は
- 大部分がObjective-C
- 一部画面のみVIPERアーキテクチャ
- ライブラリ管理はCocoaPods管理
などレガシーな技術が集結していました。この状況を打破し、持続可能で高品質な開発を実現するため、全面的な刷新を決断しました。
アーキテクチャの選定
現代的なアプリ構造を実現するため、MVVM + Clean Architectureを採用しました。選定の決め手となったのは、開発メンバー全員に知見があり SwiftUI との親和性が高いという点です。
トレンドであるTCAも視野に入れましたが、当時のチームメンバーのSwiftUI習熟度や、リニューアルという『絶対に失敗できない(期限がある)』プロジェクト特性を考慮し、よりメンバーが既存知識を活かして安全に走れるMVVM + Clean Architectureを選択しました。技術選定は『流行り』だけでなく『チームの状況』に合わせることが重要だと再認識しました。
マルチパッケージ構成と Swift 6
マルチパッケージ構成の設計は、弊社の別サービスであるiOSアプリ「ニフティ不動産 賃貸版」の先行事例を踏襲しています。基本的な依存関係は以下の三層構造で構成されています。
- App:アプリケーションのエントリポイント
- Feature:特定の機能(温泉地図検索、温泉施設詳細、お気に入りなど)
- Core:アプリ全体で共通して使用されるビジネスロジック(API通信、データモデルなど)
依存関係は App -> Feature -> Core となり、レイヤー間の依存方向が一方通行に制限されるため、依存関係が明確になりました。

これにより、一部のコード変更が全体に影響を及ぼすリスクを低減しています。詳細については、過去のテックブログを参照ください。
https://tech.niftylifestyle.co.jp/entry/1707#chapter-2
また全てのパッケージで Swift6 を採用しています。これにより、データ競合の検知といった安全機能が早期から適用され、バグの混入を未然に防ぎます。さらに、エラーハンドリングをより厳密にするTyped throwsの利用が可能になったことで、開発体験が向上しました。
SwiftUIへの全面移行
画面構築のフレームワークは、従来の UIKit ベースから全ての画面をSwiftUIへと移行しました。
さらに、マルチモジュール構成で課題となりがちな循環参照の防止と画面遷移の一元管理を実現するため、Routerパターンを導入しました。このパターンにより、Featureモジュールは遷移先を知る必要がなくなります。
Routerパターンの実装概要
Routerモジュールの定義
- Router クラスで遷移先を表す NavigationDestination と、遷移パスを保持する NavigationPath を管理
- navigate() で navigationPath に NavigationDestination を追加
@MainActor
public final class Router: ObservableObject {
public enum NavigationDestination {
case view1
case view2
// ... 遷移先を網羅
}
@Published public var navigationPath = NavigationPath()
public func navigate(to destination: NavigationDestination) {
navigationPath.append(destination)
}
public func navigateBack() {
if !navigationPath.isEmpty {
navigationPath.removeLast()
}
}
}
エントリポイントでの一元管理
- アプリケーションのエントリポイントである MainApp で Router を管理
- 全ての画面遷移先は navigation() で定義
@main
struct MainApp: App {
@StateObject private var router = Router()
var body: some Scene {
WindowGroup {
NavigationStack(path: $router.navigationPath) {
}
.environmentObject(router)
.navigationDestination(for: Router.NavigationDestination.self) { destination in
navigation(destination)
}
}
}
}
extension MainApp {
@ViewBuilder
private func navigation(_ destination: Router.NavigationDestination) -> some View {
switch destination {
case .view1:
View1()
case .view2:
View2()
}
}
}
この構成により、以下のメリットが得られました。
- 可読性の向上
- Routerに遷移先を網羅し、全ての遷移を MainApp に定義することで、アプリケーション全体の遷移ルートの把握が容易になり、可読性が大幅に向上しました。
- モジュール間の依存関係の解消
- 各Featureパッケージの画面から
router.navigate(to: 遷移先画面)を呼び出すだけで遷移が完了します。これにより、モジュール間で循環参照することなく画面遷移が可能です。
- 各Featureパッケージの画面から
Swift Concurrencyへの全面移行
非同期処理は全て async/await で実装しています。また、Combine のようなリアクティブプログラミングの代替としては、標準の AsyncStream や、Apple製のOSSである swift-async-algorithms を採用しました。
MVVMとSwift Concurrencyの連携ルール
MVVMの原則を厳守しつつ、コードの安全性と安定性を高めるため、以下の明確なルールを定義しました。
ViewModel
- ViewModel 全体を MainActor にする
- ViewModel に定義するメソッドは全て async にする
- View の更新は UIState で行う
struct FacilityUIState {
var facilities: [FacilityModel] = []
// ローディング状態やエラーメッセージなどもここに集約
}
@MainActor
final class FacilityViewModel {
// Published で公開するのは uiState のみ
@Published private(set) var uiState: FacilityUIState
func getFacilities() async {}
func getFacilityDetail() async {}
}
View
- ViewModel へのアクションの伝搬は
enum Actionを通じて行う - async メソッドは全て task modifier で実行し Task に依存させない
struct FacilityView: View {
@StateObject private var viewModel: FacilityViewModel
@State private var action: Action?
var body: some View {
List {
ForEach(viewModel.uiState.facilities) { facility in
FacilityCell(facility) {
// セルタップイベントをactionへ通知
action = .onTapCell
}
}
}
.task {
// 画面表示タイミングの処理は action を経由しない
viewModel.getFacilities()
}
.task(id: action) {
switch action {
case .onTapCell:
await viewModel.getFacilityDetail()
default:
break
}
action = nil
}
}
}
extension FacilityView {
enum Action: Equatable {
case onTapCell
}
}
この構成により、以下のメリットが得られました。
- Task キャンセルの考慮が不要
- task modifier、特に
task(id: ...)は、id の値が変化するか View が破棄される際に、実行中の Task を自動でキャンセルします。これにより、手動で Task を保持し、破棄するタイミングでキャンセルを実行するという煩雑な実装が一切不要になりました。
- task modifier、特に
- Action による恩恵
- ボタンタップ処理なども全て action で管理することで、View のコードがイベント処理から解放され、可読性が向上しました。また、ViewModel の async メソッドを
Task {}の形式ではなくtask(id: action)の内部から呼び出せるため、コードがより簡潔になります。
- ボタンタップ処理なども全て action で管理することで、View のコードがイベント処理から解放され、可読性が向上しました。また、ViewModel の async メソッドを
- 安全性の担保
- ViewModel全体を @MainActor にしたことで @Published プロパティの更新が常にメインスレッドで行われることが保証されます。
Swift Dependencies の導入
アプリの品質担保とマルチモジュール構成の維持のため、依存性注入(DI)ライブラリとして Swift Dependencies を採用しました。
採用の決め手は、コンパイル時の安全性の高さと、テスト環境へ依存オブジェクトを手軽にモックへ差し替えられる点です。
View Building Client を使った画面遷移の解決
すでに Router パターンによる NavigationStack を用いたナビゲーション遷移の課題は解決していますが、それとは別に、モーダル表示やタブ切り替えなど、View を直接インスタンス化して表示する場合に、モジュール間の循環参照が発生するという問題が残ります。
Swift Dependencies は、このView 生成時のモジュール依存に起因する循環参照を防止する役割も担っています。
Router モジュールでの定義
- ViewBuildingClient で、遷移先の View を生成するクロージャを定義
- このクロージャは View を型消去した AnyView として公開
- DependencyValues 拡張を通じて viewBuildingClient を DI のキーとして登録
@MainActor
public struct ViewBuildingClient {
public var view1: () -> AnyView
// 引数あり
public var view2: (
_ onDismiss: @escaping () -> Void
) -> AnyView
public init(
view1: @escaping () -> AnyView,
view2: @escaping (
_ onDismiss: @escaping () -> Void
) -> AnyView
) {
self.view1 = view1
self.view2 = view2
}
}
extension DependencyValues {
public var viewBuildingClient: ViewBuildingClient {
get { self[ViewBuildingClient.self] }
set { self[ViewBuildingClient.self] = newValue }
}
}
App での依存登録
- App パッケージ内で DependencyKey として liveValue を実装し、各クロージャ内で具体的な View を生成して AnyView として返却
extension ViewBuildingClient: DependencyKey {
// 依存登録
public static var liveValue: ViewBuildingClient {
.init {
AnyView(View1())
} view2: { onDismiss in
AnyView(View2(onDismiss: onDismiss))
}
}
}
Feature モジュールでの利用
@Dependency(.viewBuildingClient.view1)を通じて View の生成クロージャを取得し、遷移時にそれを実行
struct FacilityView: View {
// viewBuildingClient から画面を取得
@Dependency(\.viewBuildingClient.view1) private var view1
@Dependency(\.viewBuildingClient.view2) private var view2
}
この構成により、以下のメリットが得られました。
- モジュール間の依存関係の解消と循環参照の防止
- 全ての View が AnyView としてアップキャストされ、ViewBuildingClient を通じて間接的に公開されます。
- AnyView の利用はパフォーマンス面でのトレードオフとなりますが、画面生成に限定した利用のため AnyView を許容しています。
- 各 Feature モジュールは、遷移先の具体的な View 型を知る必要がなくなり、モジュール同士が疎結合となるため、循環参照を完全に防止することができました。
- 全ての View が AnyView としてアップキャストされ、ViewBuildingClient を通じて間接的に公開されます。
- 自動生成によるデメリットの解消
- 新規の画面が増えるたびに ViewBuildingClient の定義を更新する必要があるというデメリットがありますが、これに対しては Sourcery を使用したコード自動生成を導入し、手動での実装コストを排除しています。
なお、ビジネスロジックである UseCase についても、View とほぼ同様の方法で依存登録を実現しており testValue を通じてモックへの差し替えをしています。
リニューアルの成果(数字で見る改善)
- Objective-C ソースコード:0%
- 削除された .h / .m ファイル:約 570 ファイル
- 削除された総行数:約 150,000 行以上
- Swift の割合:99.2%
- クラッシュフリー率:99% 以上を維持
最後に
今回のリニューアルは、単なるデザイン変更や機能追加に留まらず、アプリを現代の最新ベストプラクティスへと完全に置き換える大事業でした。
本記事ではアーキテクチャに焦点を当ててご紹介しましたが、リニューアルに伴い、CI/CD環境の整備や Swift Testing の導入など、まだまだ紹介しきれなかった取り組みが多くあります。
これらの取り組みについても、ぜひ次回のブログで詳しくお伝えできればと考えています。
今回のリニューアルで基盤は整いましたが、改善に終わりはありません。ニフティライフスタイルでは、日々の施策でユーザーに価値を届けながら、同時にレガシーなシステムをモダンな技術へ粘り強く刷新していけるエンジニアを求めています。『長く続くサービスを、自分の手で着実に育てていくこと』にやりがいを感じる方、ぜひ一緒に挑戦しませんか?
https://niftylifestyle.co.jp/careers/
本記事が、レガシーアプリのリニューアルや、モダンな Swift 開発に挑戦される方々にとって、一助となれば幸いです。
最後までお読みいただきありがとうございました。
掲載内容は、記事執筆時点の情報をもとにしています。